Add user, admin, and developer documentation; expand CLAUDE.md

Creates docs/ with three audience-specific guides:
- docs/user-guide.md: step-by-step form-filling instructions, field
  descriptions, FX rate explanation, receipt handling, troubleshooting
- docs/admin-guide.md: deployment instructions and exhaustive config.yml
  key/value reference with types, defaults, and constraints
- docs/developer-guide.md: architecture overview, full state model, all
  CFG key mappings, PDF engine internals, column positions, four-pass build
  process, common modification checklists, known limitations

Rewrites CLAUDE.md as a dense agent reference covering the same ground
in a compact format: code section line ranges, full config key table with
JS access patterns, validation rules, PDF coordinate system, design
decisions, and modification checklists.

https://claude.ai/code/session_01Dad69NPna53u4hucCYnVNs
This commit is contained in:
Claude 2026-05-19 09:50:11 +00:00
parent f13b2cef6d
commit 7d49759c75
No known key found for this signature in database
4 changed files with 795 additions and 82 deletions

284
CLAUDE.md
View file

@ -1,107 +1,227 @@
# Reimbursement Form App
# Reimbursement Form — Agent Reference
Static browser app that collects expense data via a form UI and generates a PDF with attached receipts. No server, no build step — one HTML file + config.yml + assets.
Static browser app that collects expense data via a form UI and generates a PDF with attached receipts. No server, no build step — one HTML file + config.yml + optional logo.
## Repository layout
```
.
├── CLAUDE.md # This file
├── README.md
├── LICENSE
├── docs/
│ ├── user-guide.md # End-user instructions
│ ├── admin-guide.md # Deployment and config.yml reference
│ └── developer-guide.md # Fork/extend instructions, design notes
└── app/
├── index.html # Entire application (~870 lines)
├── config.yml # Runtime configuration (loaded via fetch at startup)
└── assets/
└── logo.png # Optional logo (PNG or JPG)
```
## Architecture
```
reimbursement/
├── index.html # Complete app (HTML + CSS + JS, ~850 lines)
├── config.yml # All configurable values (parsed at runtime via js-yaml)
├── assets/
│ └── logo.png # Optional org logo
└── CLAUDE.md
```
Single-file app. CSS in `<style>`, JS in `<script>`. No framework, no build tools. Vanilla JS with DOM manipulation (no re-rendering — elements are created once, updated via event listeners). Two CDN dependencies loaded at runtime:
Single-file app. CSS is in `<style>`, JS is in `<script>`. No framework, no build tools. Vanilla JS with DOM manipulation (no re-rendering — elements are created once, updated via event listeners).
- **pdf-lib** 1.17.1 — PDF creation, image embedding, PDF merging (`unpkg.com`)
- **js-yaml** 4.1.0 — config.yml parsing (`unpkg.com`)
## Dependencies (CDN, loaded at runtime)
## Code sections (index.html)
- **pdf-lib** (1.17.1) — PDF creation, image embedding, PDF merging. Loaded from unpkg.
- **js-yaml** (4.1.0) — config.yml parsing. Loaded from unpkg.
No npm, no node_modules, no bundler.
## Key design decisions
- **Custom currency dropdown** (`makeCDD`): native `<select>` can't show two-line options (code + name) that collapse to code-only. Built as a positioned div with click-outside-to-close.
- **FX rate direction**: "units of line currency per 1 base currency" (e.g. 32.00000 THB per 1 USD). Conversion: `base_amount = line_amount / fx_rate`. Rate field locks to 1.00000 when line currency = base currency.
- **Receipt storage**: `File.arrayBuffer()` stored in state. Memory-heavy for many large files but acceptable for typical reimbursement volumes (10-20 receipts).
- **PDF page references**: two-pass approach. First pass renders form pages and records placeholder positions for "See page X" text. Second pass adds receipt pages, then backfills page numbers at recorded positions. Footers (Page X/Y) added last after total page count is known.
- **Period auto-fill**: previous month by default; current month if today is the last day of the month.
- **Staff name**: cached in `localStorage` under key `reimb-staff`.
| Banner | Approx lines | Contents |
|---|---|---|
| `UTILITIES` | 103131 | `uid()`, `el()`, `fmtAmt()`, `defaultPeriod()` |
| `CONFIG` | 136143 | `loadConfig()` — fetches and parses config.yml |
| `STATE` | 145155 | `state` object, `newItem()`, `newLine()` |
| `CALCULATIONS` | 157175 | `recalc()` |
| `CURRENCY DROPDOWN` | 177195 | `makeCDD()` |
| `SELECT HELPER` | 197208 | `makeSelect()` |
| `FORM RENDERING` | 210469 | `render()`, `renderItem()`, `renderLine()`, `buildReceiptArea()` |
| `VALIDATION` | 471500 | `validate()` |
| `PDF ENGINE` | 502786 | `generatePDF()` and helpers |
| `GENERATE HANDLER` | 831854 | `onGenerate()` |
| `INIT` | 856869 | `init()` |
## State model
```
state.staff string
state.periodFrom string (YYYY-MM-DD)
state.periodTo string (YYYY-MM-DD)
state.items[]
.id string (uid)
.name string
._subtotal number (calculated, base currency)
.lines[]
.id string (uid)
.date string (YYYY-MM-DD)
.description string
.currency string (ISO code)
.fxRate string (5 decimal places)
.vendor string
.hasReceipt boolean
.receipts[] { name, type, data: ArrayBuffer }
.noReceiptExplanation string
.amount string
.account string
.program string
.programOther string
```js
state = {
staff: string, // persisted to localStorage('reimb-staff')
periodFrom: string, // YYYY-MM-DD
periodTo: string, // YYYY-MM-DD
baseCurrency: string, // ISO code; from CFG['currency-base']
fxRateMemory: {}, // { [code]: '00.00000' } session cache
items: Item[],
_grandTotal: number // computed by recalc()
}
Item = {
id: string,
name: string,
lines: Line[],
_subtotal: number // computed by recalc()
}
Line = {
id: string,
date: string, // YYYY-MM-DD
description: string,
currency: string, // ISO code
fxRate: string, // '0.00000'; units of line currency per 1 base currency
vendor: string,
hasReceipt: boolean,
receipts: Receipt[],
noReceiptExplanation: string,
amount: string, // in line currency
account: string, // from CFG.accounts[]
program: string, // from CFG.programs[]
programOther: string // used when program === 'Other'
}
Receipt = {
name: string,
type: string, // 'application/pdf' | 'image/png' | 'image/jpeg'
data: ArrayBuffer
}
```
No reactivity system. State is mutated directly by event listeners; `recalc()` is called explicitly after any change that affects totals.
State is mutated directly. After any change that affects totals, call `recalc()` explicitly.
## config.yml schema
## Config keys — full reference
| Key | Type | Notes |
|-----|------|-------|
| `organization` | string | Shown if logo disabled or missing |
| `logo` | `yes`/`no` | Loads `assets/logo.png`, falls back to `.jpg` |
| `logo-maxwidth` | number | cm — applied to both UI and PDF |
| `page-size` | `A4`/`letter` | PDF output size |
| `font-body`, `font-heading`, `font-monospace` | string | `Helvetica`/`Times`/`Courier` or `.ttf` path (TTF not yet implemented) |
| `font-size` | number | Base pt size for PDF |
| `accent-colour` | hex string | Applied as CSS variable and PDF heading/divider color |
| `intro` | string | Rendered on PDF above form fields |
| `footer` | string | Bottom of every PDF page |
| `currency-base` | string | ISO code, must appear in currencies list |
| `currencies[]` | `{code, name}` | Populates currency dropdowns |
| `accounts[]` | string[] | Dropdown options |
| `programs[]` | string[] | `"Other"` triggers a text field |
All config is accessed as `CFG['key']` after `jsyaml.load()`. Keys with hyphens must use bracket notation.
## PDF layout (pdf-lib coordinate system)
| JS access | YAML key | Type | Default | Notes |
|---|---|---|---|---|
| `CFG.organization` | `organization` | string | — | Org name; PDF header and logo fallback |
| `CFG.logo` | `logo` | `yes`/`no` | — | Checked as `=== true \|\| === 'yes'`; loads `assets/logo.png` then `assets/logo.jpg` |
| `CFG['logo-maxwidth']` | `logo-maxwidth` | number (cm) | unconstrained | Converted to pt by `× 28.3465` for PDF; applied as `maxWidth` CSS in UI |
| `CFG['page-size']` | `page-size` | `'A4'`/`'letter'` | `'A4'` | A4: 595.28×841.89 pt; letter: 612×792 pt |
| `CFG['font-body']` | `font-body` | string | `'Helvetica'` | Parsed but currently unused (hardcoded to StandardFonts) |
| `CFG['font-heading']` | `font-heading` | string | `'Helvetica'` | Parsed but currently unused |
| `CFG['font-monospace']` | `font-monospace` | string | `'Courier'` | Parsed but currently unused |
| `CFG['font-size']` | `font-size` | number (pt) | `10` | PDF base size; `szSm = sz-1`, `szLg = sz+4`, `lh = sz+4` |
| `CFG['accent-colour']` | `accent-colour` | hex string | `'#1a3a5c'` | Set as CSS `--accent`; converted via `parseHex()` for PDF |
| `CFG.intro` | `intro` | string | `''` | Rendered on PDF page 1 before header fields; omitted if empty |
| `CFG.footer` | `footer` | string | `''` | Bottom of every PDF page |
| `CFG['currency-base']` | `currency-base` | ISO code | — | Initial `state.baseCurrency`; must be in `currencies` list |
| `CFG.currencies` | `currencies` | `{code, name}[]` | — | Options for per-line currency dropdown |
| `CFG.accounts` | `accounts` | `string[]` | — | Options for account dropdown; selection required per line |
| `CFG.programs` | `programs` | `string[]` | — | Options for program dropdown; literal `'Other'` triggers text input |
- Origin: bottom-left. Y increases upward.
- A4: 595.28 × 841.89 pt. Letter: 612 × 792 pt.
- Margins: top 50, bottom 65, left 50, right 50.
- Cursor (`y`) starts at `pageHeight - marginTop`, decrements downward.
- `needSpace(h)` checks if `y - h < marginBottom`; if so, calls `addPage()` which draws the continuation header.
- Fonts: StandardFonts only (Helvetica, HelveticaBold, Courier). Custom TTF embedding is stubbed in config but not yet wired.
## Validation rules
## Common tasks
`validate()` returns `string[]`. Empty array means valid.
**Add a new form field**: add to `newLine()` defaults → add UI in `renderLine()` → add validation in `validate()` → add PDF rendering in `generatePDF()`.
- `state.staff` — required (non-empty after trim)
- `state.periodFrom`, `state.periodTo` — both required
- `state.items.length > 0` — at least one item
- Per item: `name` required; `lines.length > 0`
- Per line:
- `date` — required
- `description` — required
- `vendor` — required
- `amount` — required; must parse as positive float
- `fxRate` — required positive float if `currency !== baseCurrency`
- `account` — required
- `program` — required
- if `program === 'Other'`: `programOther` required
- if `hasReceipt === true`: `receipts.length > 0`
- if `hasReceipt === false`: `noReceiptExplanation` required
**Change PDF layout**: all layout happens in `generatePDF()`. Column positions are defined as proportions of usable width (`W`). Adjust `c1``c4` and `r2v`/`r2r`/`r2a` variables.
## PDF layout
**Add a new config option**: add to `config.yml` → read from `CFG['key-name']` in JS.
**Coordinate system:** origin bottom-left, Y increases upward.
## Deployment
**Margins:** top 50, bottom 65, left 50, right 50 (pt).
Static files served from a web server. No build step. Drop the directory at the target path (e.g. `app.capthailand.org/reimbursement/`). Ensure the server serves `.yml` files with a valid MIME type (Caddy does this by default).
**Usable width:** `W = pageW - 100`.
**Column positions** (inside line blocks):
```
c1 = 0 // Date
c2 = W * 0.22 // Vendor
c3 = W * 0.68 // Currency, Receipt label
c4 = W * 0.82 // FX rate, Amount label
```
Program: `W * 0.5`. Account: left edge.
**Header columns:** Staff at left, Period at `W * 0.5`, Currency at `W * 0.8`.
**Page break:** `needSpace(h)` checks `y - h < marginBottom`; if so, calls `addPage()` which draws a continuation header (staff name + period + divider).
**Four-pass PDF build:**
1. Form pages + receipt placeholder recording (`receiptRefs[]`)
2. Receipt pages — PDF merging and image embedding (`receiptPageMap{}`)
3. Backfill receipt references ("See page N for receipt")
4. Footers on all pages (page X/Y, printed timestamp, staff name)
**Downloaded filename:** `reimbursement_[staff]_[from]_[to].pdf` (spaces replaced with `_`).
## FX rate convention
Rate = units of line currency per 1 base currency.
```
base_amount = line_amount / fxRate
```
Example: 1000 THB at rate 34.25000 → 1000/34.25 = 29.20 USD.
The `fxRate` field is locked to `'1.00000'` (readonly) when `line.currency === state.baseCurrency`. FX rates are cached in `state.fxRateMemory[code]` while the page is open.
## Key design decisions
**`makeCDD` — custom currency dropdown:** native `<select>` cannot show code+name two-line options that collapse to code-only. Built as a positioned div with click-outside-to-close via a document-level listener.
**Receipt storage as ArrayBuffer:** files are stored raw in state, ready for pdf-lib without re-reading. Memory is proportional to total file size.
**No re-rendering:** DOM is mutated in-place. Exception: `buildReceiptArea()` replaces the receipt area div when file list or toggle changes.
**Period auto-fill:** `defaultPeriod()` returns the previous calendar month. Exception: if today is the last day of the month, returns the current month.
**localStorage key:** `reimb-staff` — staff name only; no other state is persisted.
## Common tasks (checklist)
**Add a field to an expense line:**
1. `newLine()` — add field with default
2. `renderLine()` — create DOM, wire event listener, call `recalc()` if it affects amounts
3. `validate()` — add error if required
4. `generatePDF()` — render value at appropriate position
**Add a config option:**
1. Add to `config.yml`
2. Read as `CFG['your-key']` in JS
3. No other registration needed
**Change PDF column layout:**
Edit `c1``c4` constants inside the `item.lines.forEach` block in `generatePDF()`.
**Implement custom TTF fonts:**
1. `const bytes = await fetch('assets/Font.ttf').then(r => r.arrayBuffer())`
2. `const font = await doc.embedFont(bytes)`
3. Replace `StandardFonts.Helvetica` references with the embedded font
## Known limitations
- Custom TTF fonts referenced in config are not yet embedded in PDFs (falls back to standard fonts)
- Password-protected PDF receipts will fail to merge
- No offline support (CDN dependencies)
- Large receipt volumes may stress browser memory
- PDF form layout has fixed column proportions — very long field values get truncated with `…`
| Limitation | Detail |
|---|---|
| Custom fonts not embedded | `font-body`/`font-heading`/`font-monospace` config keys parsed but ignored; hardcoded to Helvetica/Courier |
| Password-protected PDFs fail | pdf-lib cannot decrypt; error page inserted instead |
| No offline support | CDN dependencies require network on first load |
| Large receipts stress memory | All ArrayBuffers held in JS heap |
| Long text truncated with `…` | Most PDF fields truncate to fit column width; only `intro` and `noReceiptExplanation` wrap |
## Deployment
Static files served from any HTTP server. No build step. Place `app/` at the target path. The server must serve `.yml` files (Caddy does this by default; for Nginx add `text/yaml yml` to `types {}`).
Local development: `python3 -m http.server 8080` from the `app/` directory. Do not open `index.html` via `file://``fetch('config.yml')` is blocked on that origin.
## Human-readable docs
- `docs/user-guide.md` — filling in the form (staff audience)
- `docs/admin-guide.md` — deployment and all config.yml keys (admin audience)
- `docs/developer-guide.md` — architecture, state, PDF engine, modification patterns (developer audience)

200
docs/admin-guide.md Normal file
View file

@ -0,0 +1,200 @@
# Administrator Guide — Reimbursement Form
This guide is for the person responsible for deploying and configuring the reimbursement form for their organisation.
---
## Architecture
The application is a static web app — a single HTML file that loads a YAML configuration file at runtime. There is no server-side logic, no database, and no build step.
```
app/
├── index.html # Complete application
├── config.yml # All organisation-specific settings
└── assets/
└── logo.png # Optional organisation logo (PNG or JPG)
```
The browser downloads the two CDN libraries at startup:
- `pdf-lib` 1.17.1 — PDF generation
- `js-yaml` 4.1.0 — YAML config parsing
Both are loaded from `unpkg.com`. Users need an internet connection to open the form for the first time; subsequent use within the same browser session does not re-fetch them.
---
## Deployment
1. Copy the `app/` directory to any static web server (Nginx, Caddy, Apache, S3 + CloudFront, GitHub Pages, etc.).
2. Ensure the server serves `.yml` files with a valid MIME type. Caddy does this automatically. For Nginx, add:
```nginx
types { text/yaml yml; }
```
3. Access the form at the URL where `index.html` is served. No path configuration is needed inside the file.
The form works with any URL path. If you deploy at `https://intranet.example.org/finance/reimbursement/`, no changes to the source are required.
---
## Configuration reference — `config.yml`
All customisation is done in `config.yml`. The file is loaded fresh every time the page is opened, so changes take effect immediately without redeploying.
### Organisation identity
```yaml
organization: "Center for Asylum Protection"
logo: yes
logo-maxwidth: 4
```
| Key | Type | Required | Description |
|---|---|---|---|
| `organization` | string | Yes | Displayed in the form header and on the PDF. Used as a fallback if the logo is disabled or the image file is missing. |
| `logo` | `yes` / `no` | Yes | Whether to show a logo image. If `yes`, the app attempts to load `assets/logo.png`; if that fails, it tries `assets/logo.jpg`; if that also fails, it falls back to displaying the `organization` text. |
| `logo-maxwidth` | number (cm) | No | Maximum width of the logo in centimetres, applied in both the browser UI and the PDF. Default behaviour is unconstrained. Typical value: `4`. |
**Logo requirements:**
- Format: PNG (preferred) or JPG
- File must be placed at `assets/logo.png` (or `assets/logo.jpg`)
- The image is scaled to fit within `logo-maxwidth` cm width and 56 px height (UI) or 50 pt height (PDF), whichever is the binding constraint
### PDF page setup
```yaml
page-size: A4
```
| Key | Type | Required | Description |
|---|---|---|---|
| `page-size` | `A4` / `letter` | Yes | Paper size for PDF output. `A4` = 595.28 × 841.89 pt. `letter` = 612 × 792 pt. |
### Typography
```yaml
font-body: Helvetica
font-heading: Helvetica
font-monospace: Courier
font-size: 10
```
| Key | Type | Required | Description |
|---|---|---|---|
| `font-body` | string | Yes | Font used for body text in the PDF. Must be one of the standard PDF fonts: `Helvetica`, `Times`, or `Courier`. Custom TTF fonts are not yet supported. |
| `font-heading` | string | Yes | Font used for headings and labels in the PDF. Same options as `font-body`. |
| `font-monospace` | string | Yes | Font used for monospaced content. Recommended: `Courier`. |
| `font-size` | number (pt) | Yes | Base font size in points for PDF body text. Headings and labels are scaled relative to this. Typical range: 912. |
> **Note on fonts:** The current implementation always uses `Helvetica`/`HelveticaBold`/`Courier` regardless of what `font-body`, `font-heading`, and `font-monospace` are set to. Custom font selection is planned but not yet implemented.
### Branding
```yaml
accent-colour: "#1a3a5c"
```
| Key | Type | Required | Description |
|---|---|---|---|
| `accent-colour` | hex colour string | Yes | Primary brand colour. Applied as the `--accent` CSS variable in the browser UI (buttons, borders, headings) and as the heading/divider colour in the PDF. Must be a six-digit hex string including the `#` prefix. |
### Form text
```yaml
intro: ""
footer: "Confidential"
```
| Key | Type | Required | Description |
|---|---|---|---|
| `intro` | string | No | Optional introductory text shown on the first page of the PDF, above the staff/period/currency fields. Leave empty (`""`) to omit. Long text wraps automatically. |
| `footer` | string | No | Text printed at the bottom of every PDF page. Typical values: `"Confidential"`, `"Internal use only"`, or your organisation's name. |
### Currency settings
```yaml
currency-base: USD
currencies:
- code: USD
name: United States dollar
- code: THB
name: Thai baht
- code: EUR
name: Euro
- code: NOK
name: Norwegian krone
```
| Key | Type | Required | Description |
|---|---|---|---|
| `currency-base` | ISO 4217 code | Yes | The default base (reimbursement) currency. Must be present in the `currencies` list. All amounts on the PDF summary are converted to this currency. |
| `currencies` | list of `{code, name}` | Yes | All currencies available in the per-line currency dropdown. `code` must be an ISO 4217 three-letter code. `name` is shown in the dropdown for user reference only. |
**FX rate convention:** rates are expressed as units of the line currency per 1 unit of the base currency (e.g. 34.25 THB per 1 USD). Conversion is `base_amount = line_amount / fx_rate`.
**Adding a currency:** append a new `{code, name}` entry to `currencies`. No other changes are needed.
**Removing a currency:** remove its entry. Any existing PDF claims already generated are unaffected.
### Accounts
```yaml
accounts:
- "1000 - General Operations"
- "2000 - Travel & Transport"
- "3000 - Office Supplies"
- "4000 - Professional Services"
```
| Key | Type | Required | Description |
|---|---|---|---|
| `accounts` | list of strings | Yes | Dropdown options for the "Account" field on each expense line. Each string is shown verbatim. Typically formatted as `"CODE - Description"`. At least one entry is required. |
Selecting an account is mandatory for each line. The account code selected appears verbatim on the generated PDF.
### Programs
```yaml
programs:
- "General Operations"
- "Legal Aid Program"
- "Protection Program"
- Other
```
| Key | Type | Required | Description |
|---|---|---|---|
| `programs` | list of strings | Yes | Dropdown options for the "Program" field on each expense line. At least one entry is required. |
**Special value `Other`:** if the literal string `Other` is included in the list, selecting it in the form reveals an additional free-text field where the user must type the program name. The PDF will show `Other: [typed value]`. All other values are shown exactly as written.
---
## Changing configuration
1. Edit `app/config.yml` in any text editor.
2. Save the file and refresh the browser. Changes are live immediately.
3. Existing downloaded PDFs are not affected.
---
## Adding a logo
1. Prepare a PNG or JPG image of your logo. Transparent background (PNG) works best.
2. Place the file at `app/assets/logo.png` (create the `assets/` folder if it does not exist).
3. Set `logo: yes` in `config.yml`.
4. Set `logo-maxwidth` to the desired maximum width in centimetres.
---
## Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Form shows "Failed to load: Cannot load config.yml" | Server not serving the YAML file, or file missing | Check the file path and server MIME type configuration |
| Logo does not appear | File missing or wrong path | Place file at `assets/logo.png` relative to `index.html` |
| Accent colour not applied | Malformed hex value | Use the format `"#rrggbb"` (six digits, with quotes, with `#`) |
| Currency not in dropdown | Missing from `currencies` list | Add the `{code, name}` entry to `currencies` |
| `currency-base` not selectable | Code not in `currencies` list | Add it to `currencies` before using it as `currency-base` |
| PDF fonts look wrong | Custom font specified | Only `Helvetica`, `Times`, `Courier` are currently supported |

276
docs/developer-guide.md Normal file
View file

@ -0,0 +1,276 @@
# Developer Guide — Reimbursement Form
This guide is for developers who want to fork, extend, or modify the reimbursement form.
---
## Repository layout
```
.
├── CLAUDE.md # Agent-oriented reference (architecture, state, tasks)
├── README.md
├── LICENSE
├── docs/
│ ├── user-guide.md
│ ├── admin-guide.md
│ └── developer-guide.md # this file
└── app/
├── index.html # Entire application (~870 lines)
├── config.yml # Runtime configuration
└── assets/
└── logo.png # Optional organisation logo
```
---
## Technology
| Concern | Technology |
|---|---|
| UI | Vanilla JS — DOM manipulation, no framework, no virtual DOM |
| PDF generation | [pdf-lib](https://pdf-lib.js.org/) 1.17.1 via CDN |
| Config parsing | [js-yaml](https://github.com/nodeca/js-yaml) 4.1.0 via CDN |
| Build | None — single HTML file served as-is |
There is no `package.json`, no bundler, and no transpilation step. The file runs directly in any modern browser.
---
## Code structure inside `index.html`
The JS is one immediately-invoked async function. Sections are separated by `// ===` banners:
| Section | Line range | Purpose |
|---|---|---|
| UTILITIES | ~103131 | `uid()`, `el()`, `fmtAmt()`, `defaultPeriod()` |
| CONFIG | ~136143 | `loadConfig()` — fetches and parses `config.yml` |
| STATE | ~145155 | `state` object, `newItem()`, `newLine()` |
| CALCULATIONS | ~157175 | `recalc()` — recomputes subtotals and grand total |
| CURRENCY DROPDOWN | ~177195 | `makeCDD()` — custom two-line currency picker |
| SELECT HELPER | ~197208 | `makeSelect()` — standard `<select>` builder |
| FORM RENDERING | ~210469 | `render()`, `renderItem()`, `renderLine()`, `buildReceiptArea()` |
| VALIDATION | ~471500 | `validate()` — returns array of error strings |
| PDF ENGINE | ~502786 | `generatePDF()` and helpers |
| GENERATE HANDLER | ~831854 | `onGenerate()` — wires validation to PDF generation |
| INIT | ~856869 | `init()` — entry point |
---
## State model
```js
state = {
staff: string, // Full name, persisted in localStorage('reimb-staff')
periodFrom: string, // YYYY-MM-DD
periodTo: string, // YYYY-MM-DD
baseCurrency: string, // ISO code from CFG['currency-base']
fxRateMemory: {}, // { [currencyCode]: '00.00000' } — session cache
items: Item[],
_grandTotal: number // computed by recalc()
}
Item = {
id: string, // uid()
name: string,
lines: Line[],
_subtotal: number // computed by recalc()
}
Line = {
id: string, // uid()
date: string, // YYYY-MM-DD
description: string,
currency: string, // ISO code
fxRate: string, // '0.00000' — units of line currency per 1 base currency
vendor: string,
hasReceipt: boolean,
receipts: Receipt[],
noReceiptExplanation: string,
amount: string, // in line currency
account: string, // one of CFG.accounts[]
program: string, // one of CFG.programs[]
programOther: string // used when program === 'Other'
}
Receipt = {
name: string, // original filename
type: string, // MIME type: 'application/pdf', 'image/png', 'image/jpeg'
data: ArrayBuffer // raw file bytes
}
```
State is mutated directly — there is no reactivity system. After any change that affects totals, call `recalc()` explicitly.
---
## Config keys read by the JS
| JS expression | Config key | Type | Notes |
|---|---|---|---|
| `CFG['accent-colour']` | `accent-colour` | string | Hex colour applied via CSS variable and used in PDF |
| `CFG['page-size']` | `page-size` | string | `'A4'` or `'letter'` |
| `CFG['font-size']` | `font-size` | number | Base pt size; `sz` in PDF engine |
| `CFG['font-body']` | `font-body` | string | Parsed but currently unused (hardcoded to Helvetica) |
| `CFG['font-heading']` | `font-heading` | string | Parsed but currently unused |
| `CFG['font-monospace']` | `font-monospace` | string | Parsed but currently unused |
| `CFG.logo` | `logo` | `true` / `'yes'` / `false` / `'no'` | Checked with `=== true \|\| === 'yes'` |
| `CFG['logo-maxwidth']` | `logo-maxwidth` | number | cm; converted to pt by `× 28.3465` |
| `CFG.organization` | `organization` | string | Fallback text and PDF org header |
| `CFG['currency-base']` | `currency-base` | string | Initial value for `state.baseCurrency` |
| `CFG.currencies` | `currencies` | `{code, name}[]` | Currency dropdown options |
| `CFG.accounts` | `accounts` | `string[]` | Account dropdown options |
| `CFG.programs` | `programs` | `string[]` | Program dropdown options |
| `CFG.intro` | `intro` | string | Rendered on first PDF page; empty string omits it |
| `CFG.footer` | `footer` | string | Printed at bottom of every PDF page |
---
## PDF engine
### Coordinate system
pdf-lib uses a bottom-left origin. Y increases upward.
| Page size | Width (pt) | Height (pt) |
|---|---|---|
| A4 | 595.28 | 841.89 |
| letter | 612 | 792 |
Margins: top 50 pt, bottom 65 pt, left 50 pt, right 50 pt.
The cursor variable `y` starts at `pageH - marginTop` and decrements as content is drawn. `needSpace(h)` checks `y - h < marginBottom` and calls `addPage()` if a page break is needed.
### Column positions
Within each expense line the usable width `W = pageW - 100` is divided using fixed proportions:
```js
const c1 = 0; // Date column — left edge
const c2 = W * 0.22; // Vendor column
const c3 = W * 0.68; // Currency / Receipt columns
const c4 = W * 0.82; // FX rate / Amount columns
```
Program label is at `W * 0.5`. Account starts at `c1`.
### Two-pass receipt page reference
The PDF is built in four passes:
1. **Form pages:** draw all item/line content. For each receipt, record `{ pageIdx, x, y, key }` in `receiptRefs[]` — placeholder positions.
2. **Receipt pages:** append embedded PDFs (page by page) and images (scaled to fit). Build `receiptPageMap[key] = pageNumber`.
3. **Reference backfill:** go back to each recorded position and draw `"See page N for receipt"`.
4. **Footers:** iterate all pages and draw the footer line with page X/Y and printed timestamp.
### Fonts
Three fonts are embedded from the standard PDF font set:
```js
const fontBody = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const fontMono = await doc.embedFont(StandardFonts.Courier);
```
Custom TTF embedding is not yet wired despite the config keys being present.
### Font sizes
```js
const sz = CFG['font-size'] || 10; // body
const szSm = sz - 1; // labels
const szLg = sz + 4; // title, org name
const lh = sz + 4; // line height
```
---
## Common modification tasks
### Add a new field to an expense line
Follow this checklist in order:
1. **`newLine()`** — add the field with its default value.
2. **`renderLine()`** — create and wire the DOM element; update state in an event listener; call `recalc()` if the field affects amounts.
3. **`validate()`** — add an error string if the field is required.
4. **`generatePDF()`** — render the field value at the appropriate position.
### Add a new config option
1. Add the key and value to `config.yml`.
2. Read it in JS as `CFG['your-key']` (or `CFG.yourKey` if it has no hyphens).
3. No other registration is needed — `jsyaml.load()` makes all keys available.
### Change PDF column proportions
Edit the `c1``c4` constants inside the `item.lines.forEach` block in `generatePDF()`. They are local to that scope. The header layout (Staff/Period/Currency) uses separate constants `col2 = W * 0.5` and `col3 = W * 0.8`.
### Implement custom TTF fonts
The config keys `font-body`, `font-heading`, `font-monospace` are parsed but not used. To implement:
1. Fetch the TTF file: `const fontBytes = await fetch('assets/MyFont.ttf').then(r => r.arrayBuffer())`.
2. Embed it: `const fontBody = await doc.embedFont(fontBytes)`.
3. Replace the `StandardFonts.Helvetica` call with the embedded font.
4. Do the same for bold and monospace variants.
Note: pdf-lib requires the font to be a valid OpenType/TrueType file. Subset embedding is done automatically.
### Add a new top-level form field (not per-line)
1. Add the field to `state` in the module-level `state` declaration.
2. Render it in `render()`, between the header and the divider.
3. Add validation in `validate()`.
4. Render it in `generatePDF()` in the header section (around the Staff/Period/Currency row).
---
## Key design decisions
**Custom currency dropdown (`makeCDD`):** The native `<select>` element cannot display two-line options (code on line 1, name on line 2) that collapse to code-only in the closed state. The custom dropdown is a positioned `div` with click-outside-to-close via a document-level listener.
**FX rate direction:** The rate is "units of line currency per 1 base currency". This means `base_amount = line_amount / fx_rate`. Example: if base is USD and line is THB at rate 34.25, then 1000 THB = 1000/34.25 = 29.20 USD.
**Receipt storage as ArrayBuffer:** Files are stored as `ArrayBuffer` in state (not as object URLs or base64). This keeps them ready for pdf-lib without re-reading. Memory use is proportional to total receipt file size.
**No re-rendering:** DOM elements are created once by `render()`/`renderItem()`/`renderLine()` and mutated in-place by event listeners. The only exception is the receipt area (`buildReceiptArea`), which is replaced when the file list or receipt toggle changes.
**`localStorage` for staff name:** Keyed as `reimb-staff`. This persists the most frequently retyped field across sessions without requiring a server.
**Period default logic:** `defaultPeriod()` returns the previous calendar month in most cases. Exception: if today is the last day of the month, it returns the current month (on the assumption that the user is filing for the month just ending).
---
## Known limitations and planned work
| Limitation | Notes |
|---|---|
| Custom TTF fonts not embedded | Config keys present but code uses StandardFonts only |
| Password-protected PDF receipts fail | pdf-lib cannot decrypt; an error page is inserted instead |
| No offline support | CDN-loaded libraries require internet on first load |
| Large receipts stress browser memory | ArrayBuffer approach stores all bytes in RAM |
| PDF text truncated with `…` | Long field values are cut to fit the fixed column width |
| No multi-line PDF text in most fields | Only `intro` and `noReceiptExplanation` wrap; other fields truncate |
---
## Testing
There is no automated test suite. To test manually:
1. Open `app/index.html` via a local HTTP server (e.g. `python3 -m http.server 8080` from the `app/` directory, then open `http://localhost:8080`).
2. Opening `index.html` as a `file://` URL will fail — `fetch('config.yml')` is blocked by browser security on `file://` origins.
3. Exercise the form: add items and lines, attach receipts (PDF, PNG, JPG), toggle the receipt Yes/No, use non-base currencies, and generate a PDF.
4. Verify the generated PDF: check totals, receipt embedding, page references, footers, and column layout.
---
## Deployment checklist
- [ ] `config.yml` is updated for the target organisation
- [ ] `assets/logo.png` placed if `logo: yes`
- [ ] Web server serves `.yml` with a valid MIME type (`text/yaml`)
- [ ] URL is accessible to intended users
- [ ] Browser test: open form, fill in one claim, generate PDF, verify output

117
docs/user-guide.md Normal file
View file

@ -0,0 +1,117 @@
# User Guide — Reimbursement Form
This guide is for staff submitting expense reimbursement claims.
---
## Overview
The reimbursement form runs in your browser. You fill in your expenses, attach receipts, and click one button to download a PDF. No account, login, or internet upload is needed — the PDF is generated entirely on your device.
---
## Step-by-step
### 1. Open the form
Navigate to the URL your administrator has given you (e.g. `https://app.example.org/reimbursement/`). The form loads in a few seconds. If you see an error, check your internet connection — the form needs to reach CDN servers to load its libraries.
### 2. Fill in the header fields
At the top of the form you will see four fields:
| Field | What to enter |
|---|---|
| **Staff** | Your full name. This is saved in your browser and pre-filled next time. |
| **Period from** | Start date of the expense period (defaults to the first day of last month). |
| **to** | End date of the expense period (defaults to the last day of last month). |
| **Base currency** | The currency your reimbursement will be paid in. Select from the dropdown. |
> **Period default logic:** On the last day of the current month, the period defaults to the current month instead of the previous one.
### 3. Add items
An "item" groups related expenses — for example, a trip, a project, or a category. Click **+ Add item / project / travel** to create one. Give the item a descriptive name (e.g. "Field visit to Chiang Rai, March 2026").
You can add as many items as you need and remove any item with the **✕ Remove** button.
### 4. Add expense lines
Each item contains one or more expense lines. A line represents a single purchase or payment. Click **+ Add line** inside an item to add a line.
Each line has the following fields:
#### Date
The date the expense was incurred. Use the date picker or type `YYYY-MM-DD`.
#### Vendor
The name of the shop, service provider, or payee. Example: "Bangkok Airways", "Office Depot".
#### Currency
The currency in which you paid. Select from the dropdown. Each option shows the currency code (e.g. `THB`) and the full name (e.g. "Thai baht").
#### FX rate
How many units of the line currency equal one unit of the base currency.
- Example: if base currency is USD and you paid in THB, enter the number of Thai baht per 1 US dollar (e.g. `34.25000`).
- If the line currency is the same as the base currency, this field is locked at `1.00000` and you cannot change it.
- The form remembers FX rates you have entered for each currency during the session.
> **FX rate direction:** `line_amount ÷ fx_rate = base_amount`. Always use the rate as "how many [line currency] per 1 [base currency]".
#### Description
A brief description of what was purchased or paid for. Example: "Taxi from airport to hotel".
#### Receipt
Select **Yes** if you have a receipt, **No** if you do not.
- **Yes:** Upload one or more receipt files (PDF, JPG, or PNG). Each file will be embedded at the end of the PDF output. You can remove an uploaded file and re-upload.
- **No:** A text box appears. You must explain why no receipt is available (e.g. "Toll fees — no receipt issued"). This explanation will appear on the PDF.
#### Amount
The amount paid, in the line currency (not converted). Enter a positive number. Example: `1850.00`.
#### Account
Select the accounting code that this expense belongs to. The options are configured by your administrator.
#### Program
Select the program or project this expense relates to. If you select **Other**, a text field appears where you must type the program name.
### 5. Review totals
Each item shows a **Subtotal** in the base currency. The bottom of the form shows the **Total reimbursement claim**. These update as you type.
### 6. Generate the PDF
Click **Generate Reimbursement Form**. The form validates all fields first. If anything is missing or incorrect, a red box at the top lists the problems — fix them and click again.
When validation passes, the PDF is generated in your browser and downloaded automatically. The filename is:
```
reimbursement_[Your_Name]_[from-date]_[to-date].pdf
```
### 7. Submit the PDF
Send the downloaded PDF to whoever handles reimbursements at your organisation. No further action is needed in the browser.
---
## Tips
- **Your name is remembered.** It is stored in your browser's local storage so you do not need to retype it next visit.
- **FX rates are remembered within a session.** If you enter a rate for THB and then switch to USD and back, the THB rate is restored.
- **Large receipt files slow PDF generation.** If the browser appears frozen after clicking "Generate", wait — it is processing the files.
- **Password-protected PDFs cannot be merged.** If a receipt PDF is password-protected, the output will contain an error page in its place. Use a non-protected version.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Form shows "Loading configuration…" indefinitely | Network issue or server misconfiguration | Check internet connection; contact your administrator |
| "Failed to load: Cannot load config.yml" | Server is not serving the config file | Contact your administrator |
| PDF download starts but contains error text | A receipt PDF is password-protected or corrupt | Replace the file with an unprotected version |
| Amounts show as "" in the PDF | Amount field left blank or non-numeric | Enter a valid number in the amount field |
| FX rate field is greyed out | Line currency equals the base currency | No action needed; rate is 1.00000 by definition |