mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
docs/user-guide.md — end-user guide covering the full invoice workflow
docs/admin-guide.md — config.yml reference for setting up the app
docs/dev-guide.md — codebase guide: architecture, state, functions,
localStorage, i18n, FX convention, PDF generation
CLAUDE.md — project briefing for Claude Code sessions
183 lines
9 KiB
Markdown
183 lines
9 KiB
Markdown
# Developer Guide
|
||
|
||
This is a pure static web app with no build step, no npm, and no bundler. The entire application lives in `app/index.html` — approximately 1600 lines of HTML, embedded CSS, and embedded JavaScript. Runtime configuration is loaded from `app/config.yml` via `fetch()`.
|
||
|
||
## Running Locally
|
||
|
||
Because the app uses `fetch()` to load `config.yml`, it cannot be opened directly as a `file://` URL. Serve it from any local HTTP server:
|
||
|
||
```bash
|
||
npx serve app
|
||
# or
|
||
python3 -m http.server 8000 # run from the app/ directory
|
||
```
|
||
|
||
There is no install step. The two external dependencies are loaded from CDN via `<script>` tags:
|
||
|
||
- **js-yaml 4.1.0** — parses `config.yml` at runtime
|
||
- **jsPDF 2.5.1** — generates the PDF download
|
||
|
||
## Architecture
|
||
|
||
```
|
||
app/
|
||
index.html — entire application
|
||
config.yml — runtime configuration
|
||
docs/
|
||
devdocs/
|
||
Specs.md — original specification
|
||
user-guide.md
|
||
admin-guide.md
|
||
dev-guide.md
|
||
CLAUDE.md
|
||
README.md
|
||
```
|
||
|
||
Everything runs in a single browser context. There is no component framework, no module system, and no state management library. State is held in plain global variables, the DOM is built by injecting HTML strings, and persistence is handled via `localStorage`.
|
||
|
||
## Boot Sequence
|
||
|
||
1. `loadCfg()` fetches and parses `config.yml`, sets the `cfg` and `lang` globals, then calls `boot()`.
|
||
2. `boot()` calls `buildLangBar()`, `buildForm()`, and `restoreStorage()` in sequence, then calls `loadLines()`.
|
||
3. If `loadLines()` finds no saved lines in `localStorage` it falls back to `addLine()` to produce a single fresh empty line.
|
||
4. The loading indicator is hidden once `boot()` completes.
|
||
|
||
`buildForm()` injects the entire form as an HTML string into `#form-root` and wires all event listeners. This means the DOM for the form does not exist before `boot()` runs — any code that queries form elements must run after `boot()`.
|
||
|
||
## Key Global State
|
||
|
||
```javascript
|
||
let cfg // parsed config object (set once in loadCfg)
|
||
let lang // active language code, e.g. "en"
|
||
let lid // line ID counter; auto-increments on each addLine() call
|
||
const lines // map: line ID → {} (key presence = line exists)
|
||
let tlid // tax-line ID counter
|
||
const tLines // map: tax-line ID → {}
|
||
let _loading // true while loadLines() is restoring from localStorage
|
||
```
|
||
|
||
`_loading` is the most important guard to understand. `saveLines()` is a no-op while `_loading` is `true`. This prevents partial state from being written to `localStorage` during restoration — without this guard, each `addLine()` call during restore would overwrite the full saved state with progressively incomplete data.
|
||
|
||
## Core Functions
|
||
|
||
### Configuration and i18n
|
||
|
||
`t(key)` is the i18n lookup function. It returns `cfg.translations[key][lang]`, falling back to the default language code defined in config if the active language has no entry for that key. All UI strings are defined in `config.yml` under `translations:`, keyed by string identifier with one entry per language code.
|
||
|
||
`relabel()` re-applies translations to the existing DOM when the user switches language. Switching language updates `lang` and calls `relabel()` — it does not rebuild the form.
|
||
|
||
`buildLangBar()` constructs the language switcher from the `languages:` list in config.
|
||
|
||
### Form and Charge-To
|
||
|
||
`buildForm()` injects the full form HTML and attaches all event listeners. It is called once during boot.
|
||
|
||
`fillChargeTo(v)` responds to the charge-to dropdown:
|
||
- `""` — locks all charge-to fields
|
||
- `"__other__"` — unlocks all fields for free-text entry
|
||
- A numeric index — fills fields from the matching config entry and locks them, then calls `updateProjectCodes()` with that client's project code list
|
||
|
||
`updateProjectCodes(codes)` rebuilds the project code dropdown. If `codes` is `null` it falls back to the global list from config.
|
||
|
||
### Invoice Lines
|
||
|
||
`addLine()` adds a row to the line-item table. All cells (QTY, UOM, Price) start disabled until a product is selected. Returns the new line ID.
|
||
|
||
`pickProduct(i)` handles the description dropdown for line `i`:
|
||
- `""` — locks all cells
|
||
- `"__other__"` — unlocks QTY, UOM, and Price for free entry
|
||
- A predefined product — fills UOM and Price from config, locks UOM, unlocks QTY and Price
|
||
|
||
It calls `saveLines()` after updating state.
|
||
|
||
`toggleFx(i)` handles the foreign currency toggle for line `i`:
|
||
- `"yes"` — locks Price and inserts the FX sub-row containing exchange rate and per-item inputs
|
||
- `"no"` — removes the sub-row and re-enables Price
|
||
|
||
It calls `saveLines()` after the DOM change.
|
||
|
||
`resetLines()` asks for confirmation, clears all line DOM nodes and the `inv_lines_v1` key in `localStorage`, then adds one fresh empty line.
|
||
|
||
### Calculation
|
||
|
||
`calcFxFromPer(i)` recomputes the local-currency unit price for line `i` using the FX inputs, then calls `calcLine(i)` and `saveLines()`.
|
||
|
||
`calcLine(i)` updates the line total display: `qty × price`. Calls `calcTotals()`.
|
||
|
||
`calcTotals()` sums all line totals to produce the subtotal, applies each tax line, and computes the final amount to pay.
|
||
|
||
### Persistence
|
||
|
||
`saveLines()` serialises the state of every active line to `localStorage` key `inv_lines_v1` as a JSON array. Each entry holds: `dsel`, `dtxt`, `qty`, `uom`, `price`, `fx`, `fcur`, `frate`, `fper`. It is a no-op when `_loading` is `true`.
|
||
|
||
`loadLines()` reads `inv_lines_v1`, recreates the DOM for each saved line with field states matching the saved values, and calls `saveLines()` at the end. It sets `_loading = true` for the duration and resets it before the final `saveLines()` call. Returns `true` if lines were restored, `false` otherwise.
|
||
|
||
`saveStorage()` writes every field marked `[data-ls]` to `inv_data_v1` as JSON. This covers sender details, currency, project code, invoice number, and payment fields.
|
||
|
||
`restoreStorage()` reads `inv_data_v1` and repopulates `[data-ls]` fields. If the `inv_generated_v1` flag is set it also bumps the invoice number, then clears the flag.
|
||
|
||
### Preview and PDF
|
||
|
||
`gatherData()` collects all current form values into a plain object. Both `buildPreviewHTML()` and `buildPDF()` call this function — it is the single source of truth for what ends up in the output.
|
||
|
||
`buildPreviewHTML()` returns an HTML string rendered in an overlay so the user can review the invoice before downloading.
|
||
|
||
`buildPDF()` draws the invoice onto a jsPDF document using Helvetica, in the page format specified by config (`a4` or `letter`), with units in mm. Layout order: 2×2 header grid (Sender | Invoice meta, then Charge-to | Payment), line-items table, totals block. The function manages its own Y cursor and calls `doc.addPage()` when content would overflow the current page.
|
||
|
||
## localStorage Keys
|
||
|
||
| Key | Contents |
|
||
|---|---|
|
||
| `inv_data_v1` | JSON: all `[data-ls]` field values |
|
||
| `inv_lines_v1` | JSON array: one object per line |
|
||
| `inv_generated_v1` | `"true"` after Generate is clicked; triggers invoice number bump on next load |
|
||
| `zoomIdx` | Integer 0–7 index into the `ZOOMS` array for accessibility font size |
|
||
|
||
## FX Calculation
|
||
|
||
Exchange rate is expressed as "X units of foreign currency = 1 unit of local currency". The local-currency unit price is therefore:
|
||
|
||
```
|
||
price (local) = per-item (foreign) ÷ exchange rate
|
||
```
|
||
|
||
`updateFxLabels(i)` keeps the exchange-rate and per-item input labels in sync with the actual currency codes shown in the FX sub-row.
|
||
|
||
When the user changes either the per-item amount or the exchange rate, `calcFxFromPer(i)` recomputes `price` and propagates it through `calcLine(i)`.
|
||
|
||
## i18n
|
||
|
||
All translatable strings live in `config.yml` under `translations:`. Each key maps to an object whose properties are language codes:
|
||
|
||
```yaml
|
||
translations:
|
||
invoice-label:
|
||
en: Invoice
|
||
fr: Facture
|
||
```
|
||
|
||
`t(key)` resolves the string for the active `lang`, falling back to `cfg["default-code"]` if the key is missing for that language.
|
||
|
||
To add a new language:
|
||
1. Add an entry to `languages:` in config with a `direction` value (`ltr` or `rtl`).
|
||
2. Add a translation entry for every existing key under `translations:`.
|
||
3. No code changes are needed — `buildLangBar()` and `t()` are driven entirely by config.
|
||
|
||
## Extending the App
|
||
|
||
### Adding a config field to the PDF
|
||
|
||
1. Add the field to `config.yml`.
|
||
2. Read it in `loadCfg()` if it is needed globally, or read it inside `gatherData()` via `g("field-id")` if it is per-invoice.
|
||
3. Add the corresponding HTML input inside `buildForm()`. Give it a `data-ls` attribute if the value should persist across sessions.
|
||
4. Render the value in `buildPreviewHTML()` and `buildPDF()`.
|
||
|
||
### Adding a new invoice line field
|
||
|
||
1. Add the cell to the row HTML template inside `addLine()`.
|
||
2. Serialise it in `saveLines()` and deserialise it in `loadLines()`, matching the field name used in the saved object.
|
||
3. Update `gatherData()` to collect the value and add rendering logic in `buildPreviewHTML()` and `buildPDF()`.
|
||
|
||
### Changing PDF layout
|
||
|
||
`buildPDF()` owns its Y cursor as a local variable. Find the section you want to change and adjust the coordinate arithmetic. Page overflow is handled by a guard that calls `doc.addPage()` and resets the cursor — update this threshold if you add content that changes page height.
|