mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 08:04:31 +00:00
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:
parent
f13b2cef6d
commit
7d49759c75
4 changed files with 795 additions and 82 deletions
284
CLAUDE.md
284
CLAUDE.md
|
|
@ -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` | 103–131 | `uid()`, `el()`, `fmtAmt()`, `defaultPeriod()` |
|
||||
| `CONFIG` | 136–143 | `loadConfig()` — fetches and parses config.yml |
|
||||
| `STATE` | 145–155 | `state` object, `newItem()`, `newLine()` |
|
||||
| `CALCULATIONS` | 157–175 | `recalc()` |
|
||||
| `CURRENCY DROPDOWN` | 177–195 | `makeCDD()` |
|
||||
| `SELECT HELPER` | 197–208 | `makeSelect()` |
|
||||
| `FORM RENDERING` | 210–469 | `render()`, `renderItem()`, `renderLine()`, `buildReceiptArea()` |
|
||||
| `VALIDATION` | 471–500 | `validate()` |
|
||||
| `PDF ENGINE` | 502–786 | `generatePDF()` and helpers |
|
||||
| `GENERATE HANDLER` | 831–854 | `onGenerate()` |
|
||||
| `INIT` | 856–869 | `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
200
docs/admin-guide.md
Normal 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: 9–12. |
|
||||
|
||||
> **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
276
docs/developer-guide.md
Normal 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 | ~103–131 | `uid()`, `el()`, `fmtAmt()`, `defaultPeriod()` |
|
||||
| CONFIG | ~136–143 | `loadConfig()` — fetches and parses `config.yml` |
|
||||
| STATE | ~145–155 | `state` object, `newItem()`, `newLine()` |
|
||||
| CALCULATIONS | ~157–175 | `recalc()` — recomputes subtotals and grand total |
|
||||
| CURRENCY DROPDOWN | ~177–195 | `makeCDD()` — custom two-line currency picker |
|
||||
| SELECT HELPER | ~197–208 | `makeSelect()` — standard `<select>` builder |
|
||||
| FORM RENDERING | ~210–469 | `render()`, `renderItem()`, `renderLine()`, `buildReceiptArea()` |
|
||||
| VALIDATION | ~471–500 | `validate()` — returns array of error strings |
|
||||
| PDF ENGINE | ~502–786 | `generatePDF()` and helpers |
|
||||
| GENERATE HANDLER | ~831–854 | `onGenerate()` — wires validation to PDF generation |
|
||||
| INIT | ~856–869 | `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
117
docs/user-guide.md
Normal 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 |
|
||||
Loading…
Reference in a new issue