mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
Add user, admin, dev documentation and CLAUDE.md
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
This commit is contained in:
parent
f07b1e9ae0
commit
d0c5249bcb
4 changed files with 732 additions and 0 deletions
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# CLAUDE.md — Freelance Invoicing App
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
Single-file browser app for generating freelance invoices as PDF or print preview. No backend, no build step. All app logic lives in `app/index.html` (~1600 lines: HTML + embedded CSS + embedded JS). Runtime config is loaded from `app/config.yml` via `fetch()`.
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
Must be served over HTTP — `fetch()` for `config.yml` fails on `file://`.
|
||||||
|
|
||||||
|
```
|
||||||
|
npx serve app # recommended
|
||||||
|
python3 -m http.server 8000 # run from app/
|
||||||
|
```
|
||||||
|
|
||||||
|
No `package.json`, no bundler, no install step.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
index.html — entire application
|
||||||
|
config.yml — runtime config (recipients, products, translations, etc.)
|
||||||
|
docs/
|
||||||
|
devdocs/Specs.md
|
||||||
|
user-guide.md / admin-guide.md / dev-guide.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**CDN dependencies** (loaded via `<script>` tags in `index.html`):
|
||||||
|
- `js-yaml 4.1.0` (cdnjs) — parses `config.yml`
|
||||||
|
- `jsPDF 2.5.1` (cdnjs) — PDF generation
|
||||||
|
|
||||||
|
**Boot sequence:** `loadCfg()` → `boot()` → `buildLangBar()`, `buildForm()`, `restoreStorage()`, `loadLines() || addLine()`
|
||||||
|
|
||||||
|
**Key globals:**
|
||||||
|
- `cfg` — parsed config object
|
||||||
|
- `lang` — active language code
|
||||||
|
- `lid` / `lines` — line ID counter + map of active invoice line IDs
|
||||||
|
- `tlid` / `tLines` — tax-line ID counter + map
|
||||||
|
- `_loading` — bool; `true` while `loadLines()` restores from localStorage; blocks `saveLines()` writes
|
||||||
|
|
||||||
|
**localStorage keys:**
|
||||||
|
- `inv_data_v1` — form field values (all `[data-ls]` elements)
|
||||||
|
- `inv_lines_v1` — serialised line array
|
||||||
|
- `inv_generated_v1` — bump flag; invoice number increments on next load after Generate
|
||||||
|
- `zoomIdx` — font-size index
|
||||||
|
|
||||||
|
**PDF / preview:** `gatherData()` feeds both `buildPreviewHTML()` (overlay) and `buildPDF()` (file download). Uses jsPDF with Helvetica, mm units; paper size from config (`a4` or `letter`).
|
||||||
|
|
||||||
|
**i18n:** `t(key)` looks up `cfg.translations[key][lang]`, falls back to `default-code`. `relabel()` re-applies all translatable labels on language switch.
|
||||||
|
|
||||||
|
## Critical state & invariants
|
||||||
|
|
||||||
|
Do not break these behaviours:
|
||||||
|
|
||||||
|
- **Charge-to:** value `""` → all CT fields locked. Predefined entry → fill + lock. `"other"` → unlock.
|
||||||
|
- **Line description:** value `""` → QTY/UOM/Price disabled. Predefined → UOM locked, Price editable. `"other"` → all three editable.
|
||||||
|
- **FX:** FX Yes → Price locked, calculated as `per_item_foreign / exchange_rate`. FX No → Price re-enabled.
|
||||||
|
- **`_loading` guard:** `saveLines()` must no-op when `_loading === true`; set `_loading = true` for the entire duration of `loadLines()`.
|
||||||
|
- **`addLine()` must `return i`** — `loadLines()` depends on the returned ID.
|
||||||
|
- **`saveStorage()`** is triggered by `change` events on all `[data-ls]` elements; do not remove that listener.
|
||||||
|
- **Invoice number bump:** occurs on the next page load after Generate via the `inv_generated_v1` flag; do not bump on Generate itself.
|
||||||
|
- **FX rate convention:** X foreign units = 1 local unit. `price_local = price_foreign / exchange_rate`.
|
||||||
|
|
||||||
|
## Config structure (`config.yml`)
|
||||||
|
|
||||||
|
| Section | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `default-code` / `languages` | Available UI languages and default |
|
||||||
|
| `hide-payment-info` | Bool; omits payment block from PDF |
|
||||||
|
| `date-format` / `paper-format` | Output formatting |
|
||||||
|
| `tax-types` | Tax rows available in the tax table |
|
||||||
|
| `uom` | Units of measure for line items |
|
||||||
|
| `charge-to` | Recipients; each may have `project-codes` and `currency` |
|
||||||
|
| `project-codes` | Global fallback project code list |
|
||||||
|
| `products` | Predefined line items; multilingual `description`/`uom`/`price` |
|
||||||
|
| `currencies` | Currency codes and exchange rates |
|
||||||
|
| `translations` | All UI strings keyed by language code |
|
||||||
|
|
||||||
|
Config changes (new recipients, products, translations) **never require JS changes**.
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
### Add a persisted form field
|
||||||
|
1. Add the element to `buildForm()` with a `data-ls="your_key"` attribute.
|
||||||
|
2. Nothing else — `saveStorage()` and `restoreStorage()` handle it automatically.
|
||||||
|
3. If the label is translatable, add the key to `cfg.translations` in `config.yml` and update `relabel()` to set it.
|
||||||
|
|
||||||
|
### Add a new line-level field
|
||||||
|
1. Add the input to the line row HTML in `addLine()`.
|
||||||
|
2. Add the field to the object written in `saveLines()`.
|
||||||
|
3. Read it back in `loadLines()` when reconstructing each line.
|
||||||
|
4. If translatable, update `relabel()`.
|
||||||
|
|
||||||
|
### Add a new language
|
||||||
|
1. Add the language code to `languages` in `config.yml`.
|
||||||
|
2. Add a translation entry for every key in `cfg.translations` using the new code.
|
||||||
|
3. Set `default-code` to the new code if it should be the default.
|
||||||
|
4. No JS changes needed; `buildLangBar()` and `t()` pick it up automatically.
|
||||||
|
|
||||||
|
### Add a new product
|
||||||
|
Add an entry under `products` in `config.yml`:
|
||||||
|
```yaml
|
||||||
|
products:
|
||||||
|
- id: my_product
|
||||||
|
description:
|
||||||
|
en: "Widget"
|
||||||
|
fr: "Gadget"
|
||||||
|
uom:
|
||||||
|
en: "ea"
|
||||||
|
price: 99.00
|
||||||
|
```
|
||||||
|
No JS changes needed.
|
||||||
327
docs/admin-guide.md
Normal file
327
docs/admin-guide.md
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
# Administrator Guide
|
||||||
|
|
||||||
|
All runtime configuration for this invoicing application lives in a single file: `app/config.yml`. No HTML or JavaScript needs to be modified to adapt the app to a new organisation. Edit the YAML, save, and reload the browser tab — that is the entire deployment cycle.
|
||||||
|
|
||||||
|
This guide covers every section of the file in the order it appears, plus a complete walkthrough for adding a new language.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment and Serving
|
||||||
|
|
||||||
|
The application fetches `config.yml` at startup using an HTTP request. It will not work when opened directly as a local file (`file://` URLs block cross-origin fetches even within the same directory). It must be served by a web server.
|
||||||
|
|
||||||
|
The simplest option from the repository root is:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx serve app
|
||||||
|
```
|
||||||
|
|
||||||
|
This serves everything inside the `app/` directory on `http://localhost:3000` (or the next available port). Any other static file server pointed at the `app/` directory works equally well — nginx, Apache, Caddy, Python's `http.server`, or a cloud storage bucket with static hosting.
|
||||||
|
|
||||||
|
No build step, database, or server-side runtime is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Default Language
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
default-code: en
|
||||||
|
default-name: English
|
||||||
|
default-direction: ltr
|
||||||
|
```
|
||||||
|
|
||||||
|
These three keys determine the language the app starts in before the user changes anything.
|
||||||
|
|
||||||
|
- `default-code` must exactly match the `code` of one of the entries in the `languages` list below.
|
||||||
|
- `default-name` is a human-readable label used only as a hint in this section; it is not read programmatically.
|
||||||
|
- `default-direction` must be `ltr` (left-to-right) or `rtl` (right-to-left). Set this to `rtl` if the default language is Arabic, Hebrew, Persian, or any other right-to-left script. The app applies CSS direction and layout mirroring based on this value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Languages
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
languages:
|
||||||
|
- code: en
|
||||||
|
name: English
|
||||||
|
direction: ltr
|
||||||
|
- code: de
|
||||||
|
name: Deutsch
|
||||||
|
direction: ltr
|
||||||
|
```
|
||||||
|
|
||||||
|
This list controls which languages appear in the language selector. If the list contains exactly one entry, no selector is shown and the UI is fixed to that language.
|
||||||
|
|
||||||
|
Each entry has three required fields:
|
||||||
|
|
||||||
|
- `code` — a short identifier, typically an IETF language tag such as `en`, `de`, `fr`, or `ar`. This value is used as the lookup key throughout the rest of the config (in `labels`, `description`, and `translations` maps). Every language code used elsewhere in the file must appear here.
|
||||||
|
- `name` — the display name shown in the language selector dropdown, written in that language (e.g. `Deutsch` rather than `German`).
|
||||||
|
- `direction` — `ltr` or `rtl`. Setting this to `rtl` causes the entire UI to mirror for right-to-left reading, including the invoice PDF layout.
|
||||||
|
|
||||||
|
YAML treats bare `no` as the boolean `false`, so Norwegian (`no`) must always be quoted: `"no"`. This applies to the language code entry itself and to every map key that uses it elsewhere in the file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Payment Info Visibility
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hide-payment-info: false
|
||||||
|
```
|
||||||
|
|
||||||
|
When set to `true`, the entire Payment card is removed from the invoice form and the payment block (account holder, account number, BIC, bank address, payment reference) is omitted from the generated PDF. Set this if your organisation invoices through a platform that handles payment separately, or if displaying bank details on invoices is not appropriate for your context.
|
||||||
|
|
||||||
|
Set to `false` (the default) to show the payment section normally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Date and Paper Format
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
date-format: "d MMMM YYYY"
|
||||||
|
paper-format: a4
|
||||||
|
```
|
||||||
|
|
||||||
|
### date-format
|
||||||
|
|
||||||
|
Controls how dates are rendered on the invoice. The value is a format string using the following tokens:
|
||||||
|
|
||||||
|
| Token | Output |
|
||||||
|
|-------|--------|
|
||||||
|
| `d` | Day of the month without leading zero (1, 2, … 31) |
|
||||||
|
| `dd` | Day of the month with leading zero (01, 02, … 31) |
|
||||||
|
| `M` | Month number without leading zero (1, 2, … 12) |
|
||||||
|
| `MM` | Month number with leading zero (01, 02, … 12) |
|
||||||
|
| `MMM` | Abbreviated month name (Jan, Feb, …) |
|
||||||
|
| `MMMM` | Full month name (January, February, …) |
|
||||||
|
| `YY` | Two-digit year (25, 26, …) |
|
||||||
|
| `YYYY` | Four-digit year (2025, 2026, …) |
|
||||||
|
|
||||||
|
Any separator characters (spaces, hyphens, slashes, dots) between tokens are preserved literally. Examples:
|
||||||
|
|
||||||
|
- `"d MMMM YYYY"` → `24 May 2026`
|
||||||
|
- `"dd/MM/YYYY"` → `24/05/2026`
|
||||||
|
- `"YYYY-MM-dd"` → `2026-05-24`
|
||||||
|
|
||||||
|
### paper-format
|
||||||
|
|
||||||
|
Controls the page dimensions of the generated PDF. Accepted values:
|
||||||
|
|
||||||
|
- `a4` — 210 × 297 mm (standard outside North America)
|
||||||
|
- `letter` — 8.5 × 11 inches (standard in North America)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tax Types
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tax-types:
|
||||||
|
- key: vat
|
||||||
|
labels:
|
||||||
|
en: VAT
|
||||||
|
de: MwSt.
|
||||||
|
fr: TVA
|
||||||
|
```
|
||||||
|
|
||||||
|
This section defines the tax labels available in the tax-line dropdown when the user builds an invoice. It does not set any rates — rates are always entered by the user at invoice time, which allows different invoices to use different rates for the same tax type.
|
||||||
|
|
||||||
|
Each entry has:
|
||||||
|
|
||||||
|
- `key` — an internal identifier (not shown to users) used to reference this tax type. It must be unique within the list.
|
||||||
|
- `labels` — a map of language code to display string. When the user adds a tax line, the label shown is the one matching the active language.
|
||||||
|
|
||||||
|
Add as many tax types as your jurisdiction requires. Remove any that are irrelevant to your operation. Every active language code should have a corresponding entry in each `labels` map; if a code is missing, the app will fall back to the raw key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Units of Measure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uom:
|
||||||
|
- code: HR
|
||||||
|
labels:
|
||||||
|
en: Hour
|
||||||
|
de: Stunde
|
||||||
|
fr: Heure
|
||||||
|
```
|
||||||
|
|
||||||
|
Defines the unit-of-measure options available in the invoice line dropdown. Each entry has:
|
||||||
|
|
||||||
|
- `code` — an uppercase internal identifier (e.g. `HR`, `DY`, `EA`). This code is what gets stored and is what must be referenced in the `products` section.
|
||||||
|
- `labels` — a map of language code to the human-readable label displayed in the UI and on the invoice.
|
||||||
|
|
||||||
|
Add units appropriate to your business (e.g. `KG` for kilograms, `KM` for kilometres, `PG` for pages). Remove any that are not used. At least one unit must be present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Predefined Recipients
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
charge-to:
|
||||||
|
- display: Acme Corporation
|
||||||
|
name: Acme Corporation Ltd.
|
||||||
|
address1: 123 Business Avenue
|
||||||
|
address2: Suite 400
|
||||||
|
address3: New York, NY 10001
|
||||||
|
address4: ""
|
||||||
|
country: US
|
||||||
|
phone: "+1-212-555-0100"
|
||||||
|
email: accounts@acmecorp.example
|
||||||
|
vat-id: "US-EIN-12-3456789"
|
||||||
|
reg-no: ""
|
||||||
|
currency: USD
|
||||||
|
project-codes:
|
||||||
|
- AC-100
|
||||||
|
- AC-110
|
||||||
|
```
|
||||||
|
|
||||||
|
The `charge-to` list populates the recipient dropdown on the invoice form. When the user selects a recipient, all fields are filled in automatically. This section is optional — if omitted or empty, the user fills in recipient details manually every time.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
- `display` — the short label shown in the dropdown. Does not appear on the invoice itself; it is only for the user's convenience when choosing from the list.
|
||||||
|
- `name` — the legal name printed on the invoice under the "Charge to" heading.
|
||||||
|
- `address1` through `address4` — four free-form address lines. Use as many or as few as the address requires; set unused lines to an empty string (`""`).
|
||||||
|
- `country` — the recipient's country as an ISO 3166-1 alpha-2 code (two uppercase letters, e.g. `US`, `GB`, `DE`, `NO`). This is printed on the invoice.
|
||||||
|
- `phone` — contact phone number. Printed on the invoice. May be left empty.
|
||||||
|
- `email` — contact email address. Printed on the invoice. May be left empty.
|
||||||
|
- `vat-id` — the recipient's VAT or tax registration number. Printed in the recipient block if present.
|
||||||
|
- `reg-no` — the recipient's company registration number. Printed in the recipient block if present. Set to `""` if not applicable.
|
||||||
|
- `currency` — an ISO 4217 three-letter currency code (e.g. `USD`, `EUR`, `GBP`). When this recipient is selected from the dropdown, the invoice currency is automatically set to this value. The code must appear in the `currencies` list for the currency selector to work correctly.
|
||||||
|
- `project-codes` — an optional list of project code strings specific to this recipient. When this recipient is selected, only these codes appear in the project code dropdown. If this key is absent from a recipient entry, the dropdown falls back to the global `project-codes` list.
|
||||||
|
|
||||||
|
### Adding a new recipient
|
||||||
|
|
||||||
|
Add a new list item with all the fields above. Omitting optional fields (`vat-id`, `reg-no`, `project-codes`, empty address lines) is fine as long as the key is present with an empty value where the app expects a string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Project Codes
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
project-codes:
|
||||||
|
- Project 100
|
||||||
|
- Project 110
|
||||||
|
- Project 230
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the fallback list of project codes shown in the project code dropdown when no recipient is selected, or when the selected recipient has no `project-codes` key of its own. Each item is a plain string.
|
||||||
|
|
||||||
|
Set this to the codes that span multiple clients or apply to general work. Leave the list empty (`project-codes: []`) if project codes are not used in your organisation, which causes the dropdown to show no options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Predefined Products and Services
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
products:
|
||||||
|
- code: CONSULTING
|
||||||
|
description:
|
||||||
|
en: Consulting Services
|
||||||
|
de: Beratungsleistungen
|
||||||
|
uom: HR
|
||||||
|
price: 100.00
|
||||||
|
```
|
||||||
|
|
||||||
|
The `products` list populates the description dropdown on each invoice line. When a product is selected, the description, unit of measure, and unit price are filled in automatically. The user can override any of these values after selection.
|
||||||
|
|
||||||
|
Each entry has:
|
||||||
|
|
||||||
|
- `code` — an internal identifier, not shown to users. Must be unique within the list. Conventionally uppercase.
|
||||||
|
- `description` — a map of language code to the human-readable description string printed on the invoice line. Provide a translation for every active language.
|
||||||
|
- `uom` — the unit of measure code for this product. Must exactly match a `code` value from the `uom` list.
|
||||||
|
- `price` — the default unit price as a decimal number. The user can edit this at invoice time.
|
||||||
|
|
||||||
|
Products can represent services (billed by the hour or day) or fixed-price deliverables (billed as each). Remove products that are not relevant to your work and add your own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Currencies
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
currencies:
|
||||||
|
- USD
|
||||||
|
- EUR
|
||||||
|
- GBP
|
||||||
|
- NOK
|
||||||
|
```
|
||||||
|
|
||||||
|
This list defines the options available in the invoice currency selector. Each entry is an ISO 4217 three-letter currency code. The list is used for two purposes:
|
||||||
|
|
||||||
|
1. Populating the invoice currency dropdown when the user changes the invoicing currency from the default.
|
||||||
|
2. Validating that the `currency` field on a recipient entry points to a known code.
|
||||||
|
|
||||||
|
Include every currency your organisation might invoice in. The order of the list is the order entries appear in the dropdown. There is no limit on the number of entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Label Translations
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
translations:
|
||||||
|
invoice:
|
||||||
|
en: INVOICE
|
||||||
|
de: RECHNUNG
|
||||||
|
fr: FACTURE
|
||||||
|
"no": FAKTURA
|
||||||
|
generate-invoice:
|
||||||
|
en: Generate Invoice
|
||||||
|
de: Rechnung erstellen
|
||||||
|
```
|
||||||
|
|
||||||
|
The `translations` section contains every user-facing string in the application UI. Each top-level key under `translations` corresponds to a specific label, button, column header, or section title. The value is a map of language code to translated string.
|
||||||
|
|
||||||
|
These strings appear in both the interactive form and the generated PDF. When the active language changes, every label on the page re-renders using the matching translation.
|
||||||
|
|
||||||
|
You should not need to change keys or add new keys unless you are modifying the application itself. The keys that must be present are fixed by the application code.
|
||||||
|
|
||||||
|
### Adding a translation for a new language
|
||||||
|
|
||||||
|
For each key in the `translations` section, add a new entry for your language code:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
invoice:
|
||||||
|
en: INVOICE
|
||||||
|
de: RECHNUNG
|
||||||
|
ar: فاتورة
|
||||||
|
```
|
||||||
|
|
||||||
|
Every key in the `translations` section must have an entry for every language code listed under `languages`. If a translation is missing for a given language, the app displays the raw key as a fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Language End to End
|
||||||
|
|
||||||
|
To add a new language — for example, Arabic (`ar`) — make the following changes to `config.yml` in order:
|
||||||
|
|
||||||
|
1. **Add to the languages list.** Append a new entry. For a right-to-left language, set `direction: rtl`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
languages:
|
||||||
|
- code: ar
|
||||||
|
name: العربية
|
||||||
|
direction: rtl
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Optionally set it as the default.** If Arabic should be the startup language, update the default block:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
default-code: ar
|
||||||
|
default-name: Arabic
|
||||||
|
default-direction: rtl
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add labels to every tax-type entry.** For each item under `tax-types`, add an `ar:` entry under `labels`.
|
||||||
|
|
||||||
|
4. **Add labels to every uom entry.** For each item under `uom`, add an `ar:` entry under `labels`.
|
||||||
|
|
||||||
|
5. **Add descriptions to every product entry.** For each item under `products`, add an `ar:` entry under `description`.
|
||||||
|
|
||||||
|
6. **Add a translation for every key under `translations`.** Work through every key in the `translations` map and add an `ar:` entry with the appropriate translated string.
|
||||||
|
|
||||||
|
Once all entries are present, save the file and reload the browser. The new language will appear in the language selector. For RTL languages, the layout will mirror automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Editing
|
||||||
|
|
||||||
|
Save `config.yml` and reload the browser tab. The application fetches the config fresh on every page load; there is no cache to clear and no server process to restart. Changes take effect immediately.
|
||||||
183
docs/dev-guide.md
Normal file
183
docs/dev-guide.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# 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.
|
||||||
109
docs/user-guide.md
Normal file
109
docs/user-guide.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Invoice App — User Guide
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
The app is a single web page that must be served from a web server. It cannot be opened directly as a file in your browser because it needs to load its configuration from a separate file (`config.yml`), and browsers block that kind of request from plain `file://` URLs.
|
||||||
|
|
||||||
|
To run it locally, open a terminal, go to the `app/` folder, and start a simple server with one of these commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx serve app
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```
|
||||||
|
cd app && python -m http.server
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the address shown in your terminal (usually `http://localhost:3000` or `http://localhost:8000`) in your browser.
|
||||||
|
|
||||||
|
## Your details (From)
|
||||||
|
|
||||||
|
The top-left card is labelled **From**. Fill in your name or business name, address lines, country, phone, email, and VAT / Tax ID. Everything you type here is saved automatically to your browser's local storage, so it will still be there the next time you open the app. You only need to fill this in once.
|
||||||
|
|
||||||
|
## Invoice details
|
||||||
|
|
||||||
|
The top-right card is labelled **Invoice Details**. It contains four fields:
|
||||||
|
|
||||||
|
- **Invoice date** — a date picker that defaults to today. Change it if you are backdating or forward-dating the invoice.
|
||||||
|
- **Invoice currency** — a dropdown of the currencies configured for your account. Choose the currency you want to invoice in.
|
||||||
|
- **Project code** — either pick a code from the dropdown (the list depends on which client you have selected under Charge to, or falls back to the general project code list) or choose **Other** to type a free-text code. Your last-used choice is saved.
|
||||||
|
- **Invoice no.** — a free-text field for your invoice number. You can type anything you like. After you generate an invoice, the number is automatically incremented the next time the app loads — so if your last invoice was `INV-007`, the next session will start with `INV-008`. You can always edit it manually before generating.
|
||||||
|
|
||||||
|
## Charge to (recipient)
|
||||||
|
|
||||||
|
The top-right card of the second column is labelled **Charge to**. Use the dropdown at the top to choose who you are billing.
|
||||||
|
|
||||||
|
- **A predefined client** — all address and contact fields fill in automatically and become read-only. The project code list in Invoice Details also updates to show only the codes linked to that client.
|
||||||
|
- **Other** — all fields become editable so you can type a one-off recipient.
|
||||||
|
- **— Select —** — the default state; all fields are locked and empty. The invoice cannot be generated meaningfully until you have either chosen a client or selected Other and filled in the details.
|
||||||
|
|
||||||
|
## Payment
|
||||||
|
|
||||||
|
The bottom-left card is labelled **Payment**.
|
||||||
|
|
||||||
|
- **Payment terms** — enter the number of days the client has to pay. The **Pay by** date is calculated automatically from the invoice date.
|
||||||
|
- **Bank account fields** — these fields (account holder, account number / IBAN, bank / BIC, bank address, and payment reference) are only visible when you have selected **Other** under Charge to. For predefined clients, payment instructions are managed through the configuration and appear on the invoice automatically.
|
||||||
|
|
||||||
|
## Invoice lines
|
||||||
|
|
||||||
|
The large card below the four top cards holds your invoice lines.
|
||||||
|
|
||||||
|
To start, click **+ Add new line**. Each line works as follows:
|
||||||
|
|
||||||
|
1. **Description** — choose a predefined service from the dropdown, or choose **Other** to type a custom description. Until you make a selection, the quantity, unit of measure, and unit price fields are all locked.
|
||||||
|
2. **Predefined service** — the unit of measure (e.g. Hour, Day, Each) fills in automatically from the configuration and cannot be changed on that line. The unit price fills in with the configured default but you can edit it. Enter the quantity.
|
||||||
|
3. **Other** — all fields are editable. Choose a unit of measure from the dropdown, then enter quantity and unit price.
|
||||||
|
|
||||||
|
To remove a line, click the **×** button on the right. To start fresh, click **Reset invoice lines** (top right of the card); you will be asked to confirm before anything is cleared.
|
||||||
|
|
||||||
|
All line data is saved to local storage as you type and restored when you reopen the app. If you want to clear saved lines without generating an invoice, use the Reset button.
|
||||||
|
|
||||||
|
### Foreign currency on a line
|
||||||
|
|
||||||
|
Each line has a **Foreign currency** toggle (No / Yes).
|
||||||
|
|
||||||
|
Set it to **Yes** if the client is billed in a currency that differs from your invoice currency. When you do:
|
||||||
|
|
||||||
|
- The **Unit Price** field locks — it is calculated for you and cannot be edited directly.
|
||||||
|
- Three new fields appear:
|
||||||
|
- **Currency code** — select the foreign currency (e.g. USD).
|
||||||
|
- **Exchange rate** — enter the rate in the form *X foreign = 1 local*. For example, if your invoice currency is THB and the rate is 0.028 USD per THB, enter `0.028`. This means 0.028 USD equals 1 THB, so the app divides the foreign price by this number to get the local amount.
|
||||||
|
- **Price per item** — enter the price in the foreign currency.
|
||||||
|
- A read-only **line total in the foreign currency** is displayed for your reference.
|
||||||
|
- The **Unit Price** (in your invoice currency) is calculated as: price per item ÷ exchange rate.
|
||||||
|
|
||||||
|
## Tax
|
||||||
|
|
||||||
|
Below the invoice lines is the **Totals** section. Click **+ Add new tax** to add a tax row. For each tax row:
|
||||||
|
|
||||||
|
- Enter either a **percentage** (e.g. `20` for 20%) or a **fixed amount**.
|
||||||
|
- Choose the **tax type** label: VAT, GST, Sales Tax, Withholding Tax, or Other Tax.
|
||||||
|
|
||||||
|
You can add as many tax rows as you need — for example, a VAT row and a Withholding Tax row on the same invoice.
|
||||||
|
|
||||||
|
The **Paid** field lets you record any amount already received. The **To Pay** total is calculated as: Subtotal + all tax rows − Paid.
|
||||||
|
|
||||||
|
## Generating and downloading the invoice
|
||||||
|
|
||||||
|
When everything looks right, click the **Generate Invoice** button at the bottom of the page. A preview overlay opens showing your formatted invoice.
|
||||||
|
|
||||||
|
- Review it carefully before downloading.
|
||||||
|
- Click **Download PDF** to save the invoice as a PDF file using your browser's default download location.
|
||||||
|
- Click **Close** to go back and make changes. You can generate as many times as you like before downloading.
|
||||||
|
|
||||||
|
The PDF always renders in the default language regardless of which language you have selected in the interface.
|
||||||
|
|
||||||
|
## Invoice number and auto-increment
|
||||||
|
|
||||||
|
The invoice number is saved to local storage each time you make a change. After you click Generate Invoice, the app marks that an invoice was generated. The next time you load the page, the trailing number in the invoice number is automatically incremented — for example, `2026-001` becomes `2026-002`, or `INV007` becomes `INV008`. Leading zeros are preserved.
|
||||||
|
|
||||||
|
If you need to override the number — for instance to skip a number or correct a mistake — simply edit the Invoice no. field before generating.
|
||||||
|
|
||||||
|
## Language and zoom
|
||||||
|
|
||||||
|
A small bar at the top of the page contains two controls:
|
||||||
|
|
||||||
|
- **A− / A+** — decrease or increase the text size. The zoom level is saved between sessions.
|
||||||
|
- **Language dropdown** — visible only if more than one language is configured. Switching language changes all the form labels immediately. Note that the PDF is always generated in the default language.
|
||||||
Loading…
Reference in a new issue