Invoice Builder
A live invoice builder with three visual templates, print-to-PDF, a genuinely styled .xlsx export, logo and signature uploads, and localStorage autosave.
The Invoice Builder (/invoice) is a live invoice builder — an accordion form on the left, a
real-time invoice document on the right — with 3 visual templates (Modern / Classic / Minimal),
print-to-PDF via an isolated window.print() portal, a genuinely styled .xlsx export
(lazy-loaded exceljs), logo / signature uploads, and localStorage autosave.
Overview
The page is a two-column grid (xl:grid-cols-2): the builder form — a controlled
Accordion type="multiple" with seven sections (My details / Client details / Invoice details + line
items / Tax & discount / Payment / Notes / Signature) — and a sticky live preview that re-renders
the invoice document on every keystroke. The preview Panel carries a toolbar: a template Select,
Download PDF (window.print()), Save, and a More dropdown (Save as draft / Send to client
/ Export Excel / Reset).
Everything is real: totals are computed from the line items (subtotal → tax % → flat discount),
amounts format through the locale-aware formatMoney, images upload as data URLs (≤ 1 MB), and the
working invoice autosaves to localStorage (debounced 400 ms) so it survives a reload. Save /
draft / send are presentational beyond persistence. All state and actions live in the useInvoice
view-model hook; InvoicePage is layout + wiring only.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/InvoicePage.tsx | The page: form Panel + sticky preview Panel with the template Select and action toolbar, plus the print-only copy portaled onto <body> (#invoice-print-root). |
src/components/invoice/useInvoice.ts | The view-model: editable invoice state (seeded from localStorage), open accordion sections, typed field / line-item / image setters, derived totals, the debounced autosave, and the toolbar actions. |
src/components/invoice/InvoiceForm.tsx | The left accordion form — seven AccordionItem sections built from the Field / Input / Textarea / Select / DatePicker primitives, the line-item editor rows, and the dashed logo / signature upload targets. |
src/components/invoice/InvoicePreview.tsx | The live invoice document (.invoice-print): header (template-dependent), meta row, From / Bill-to, items table, totals block, notes, and the payment + signature footer. All money runs through formatMoney. |
src/data/invoice.ts | Types, TEMPLATES / CURRENCIES / PAYMENT_METHODS option tables, formatMoney, lineAmount / computeTotals, nextItemId, the seed, and persistence. |
src/styles/_print.scss | Print isolation: hides every <body> child except #invoice-print-root, re-declares the raw tokens to light values, sets print-color-adjust: exact and the @page margin. |
Data model
export type InvoiceTemplate = 'classic' | 'modern' | 'minimal'
export interface InvoiceLineItem {
id: string
description: string
quantity: number
price: number
}
export interface InvoiceData {
company: InvoiceCompany // name, taxId, address, email, phone, website
client: InvoiceClient // name, company, address, email, phone
invoiceNumber: string
projectName: string
issueDate: string // YYYY-MM-DD
dueDate: string
items: InvoiceLineItem[]
taxRate: number // percentage, e.g. 8.25
discount: number // flat amount in the invoice currency
payment: InvoicePayment // method, currency, accountName, bankCode, accountNumber
notes: string
paymentTerms: string
signatureName: string
signatureDataUrl?: string // data: URL of an uploaded signature image
logoDataUrl?: string // data: URL of an uploaded logo image
template: InvoiceTemplate
}Option tables: TEMPLATES (3 templates, labels via invoice:tpl*), CURRENCIES (7 codes — USD, EUR,
GBP, INR, AUD, CAD, JPY — codes stay literal), PAYMENT_METHODS (5 methods, labels via
invoice:method*).
Money & totals
/** Format a number as currency in the given locale (falls back gracefully for unknown currencies). */
export function formatMoney(amount: number, currency: string, locale = 'en-US'): stringThe canonical currency formatter
formatMoney is the repo's only Intl.NumberFormat currency helper. Reuse it (with the active
i18n.language as locale) anywhere you show a currency amount; don't re-inline Intl.NumberFormat.
Unknown currency codes fall back to "CODE 12.00".
computeTotals(invoice) is pure: subtotal = Σ lineAmount(item) (quantity × price), taxAmount =
subtotal × taxRate%, discountAmount = the flat discount, total = subtotal + tax − discount.
Every step rounds to 2 decimals via an epsilon-safe round2, so binary-float artifacts (e.g.
531.3000000000001) never reach the document.
Persistence
export const STORAGE_KEY = 'invoice-state-v1'An effect in useInvoice debounce-saves the invoice 400 ms after any change; loadInvoice() restores
it on mount (falling back to seedInvoice() — issue date today, due +14 days — when absent or
invalid). STORAGE_KEY is registered in src/lib/appStorage.ts, so "Reset to defaults" wipes it.
New line-item ids continue the li-<n> sequence past the current maximum (nextItemId), so React
keys never collide after add/remove.
Usage
The builder is already wired — navigate to /invoice (Pages → Invoice in the sidebar):
{path: '/invoice', element: <InvoicePage />},The page itself loads eagerly; the heavy dependency — exceljs — is lazy-loaded inside the export
action (await import('exceljs')), so it ships as its own chunk fetched only when a user actually
exports. To reuse the state logic in a custom layout, call the hook and pass it down:
import {InvoiceForm, InvoicePreview, useInvoice} from '@/components/invoice'
function MyInvoiceScreen() {
const vm = useInvoice()
return (
<div className="grid gap-6 xl:grid-cols-2">
<InvoiceForm vm={vm} />
<InvoicePreview vm={vm} />
</div>
)
}API / Props
useInvoice()
Returns the builder view-model (UseInvoice):
| Key | Type | Description |
|---|---|---|
invoice | InvoiceData | The live editable invoice. |
totals | InvoiceTotals | Memoized {subtotal, taxAmount, discountAmount, total}. |
locale | string | The active i18n.language — pass it to formatMoney and date formatting. |
open / setOpen | string[] / setter | Controlled accordion sections (defaults to ['my', 'details'] open). |
setField | (key, value) => void | Typed top-level field setter. |
setCompany / setClient / setPayment | (key, value) => void | Nested-object field setters. |
addItem / updateItem / removeItem | line-item CRUD | addItem appends a blank row (quantity: 1, price: 0) with the next li-<n> id. |
readImage | (file, 'signatureDataUrl' | 'logoDataUrl') => void | Reads an image into a data URL; rejects files over 1 MB with a danger toast. |
save / saveDraft | () => void | Persist immediately + success / info toast. |
send | () => void | Demo action — success toast only. |
reset | () => void | Replace with a fresh seedInvoice(), persist, info toast. |
exportSheet | () => Promise<void> | Build + download a styled .xlsx via lazy-loaded exceljs. |
print | () => void | window.print() — prints the portaled copy. |
InvoiceForm / InvoicePreview
Both take a single prop vm: UseInvoice — the view-model from useInvoice().
The three templates
invoice.template switches the document's look inside InvoicePreview (the body is shared; header and
accents vary):
Full-width primary gradient header band (title, number, logo, and the total + due date
right-aligned), rounded bordered items table with a tinted head, totals in a bg-surface-muted
card, total in text-primary. This is the default.
Print-to-PDF (how it works)
"Download PDF" is window.print() — the browser's print dialog does the PDF. Two pieces make the
printout show only the invoice:
- A print-only copy on
<body>.InvoicePagerenders a second<InvoicePreview>throughcreatePortalinto<div id="invoice-print-root" className="hidden print:block">ondocument.body. It must live outside the app shell, whose transforms/overflow (page transitions, the sticky preview column) would clip or mis-offset an in-place print node. The formPaneland preview toolbar are additionallyprint:hidden. src/styles/_print.scss. Under@media printit hides every other direct child of<body>, re-declares the raw token vars on#invoice-print-rootto their light values (so the invoice always prints on white regardless of theme/skin), setsprint-color-adjust: exact, and a14mm@pagemargin.
Warning
Print is the one deliberate exception to the "colors only via tokens" rule — the hardcoded light
values in _print.scss are a mirror of :root in src/styles/index.css.
Excel export
exportSheet builds a real .xlsx with exceljs (MIT,
lazy-loaded) that mirrors the on-page template: modern gets the primary header band with the total,
classic a title with a primary bottom border, minimal a plain title with the grand total in text
color. Amounts are real numbers with a currency numFmt (the symbol is derived from
Intl.NumberFormat(...).formatToParts), dates format in the active language, and the file downloads as
<invoiceNumber>.xlsx. A plain CSV can't carry styling and the HTML-as-.xls trick triggers Excel's
format warning — hence a genuine xlsx.
Configuration & customization
Add a currency
Append to CURRENCIES — the code is the value Intl.NumberFormat receives, the label is what the
picker shows:
// src/data/invoice.ts
{value: 'CHF', label: 'CHF (Fr)'},formatMoney, the preview, and the xlsx numFmt all pick it up automatically; codes stay literal.
Add a payment method
{value: 'crypto', labelKey: 'invoice:methodCrypto'},// src/locales/en/invoice.json
{"methodCrypto": "Crypto"}Add a template
A template = a value in the InvoiceTemplate union + a TEMPLATES entry (with a tpl<Cap> i18n key)
- conditional branches in
InvoicePreview.tsx(header / table / totals treatment) and, if you want the export to match, inuseInvoice.ts'sexportSheet.
Change the seed invoice
Edit seedInvoice() in src/data/invoice.ts. Dates are generated relative to today (issue = today,
due = +14 days) so the demo always looks current.
Existing browsers
The persisted invoice (invoice-state-v1) wins over the seed. Use the toolbar's More → Reset (or
"Reset to defaults") to see seed changes during development.
Examples
Format any amount with the canonical helper
import {useTranslation} from '@/platform/i18n'
import {formatMoney} from '@/data/invoice'
function Price({amount}: {amount: number}) {
const {i18n} = useTranslation()
return <span>{formatMoney(amount, 'EUR', i18n.language)}</span> // "1.234,56 €" in de
}Compute totals for your own document
import {computeTotals, loadInvoice} from '@/data/invoice'
const invoice = loadInvoice()
const {subtotal, taxAmount, discountAmount, total} = computeTotals(invoice)Best practices
- Keep logic in
useInvoice. The page and form stay render-only; state, persistence, and the toolbar actions live in the hook. - All money through
formatMoney, all totals throughcomputeTotals. The rounding and locale handling are centralized. - Keep the print portal on
<body>. Moving#invoice-print-rootinside the shell breaks print isolation and re-shows app chrome in the printout. - Store keys, not copy. Field labels, section titles, and toasts are i18n keys; currency codes and the seed content stay literal.
- Register new persisted keys in
APP_LOCAL_KEYS(src/lib/appStorage.ts).
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| The printout shows the whole app, not just the invoice | The print copy must stay portaled to document.body as #invoice-print-root — _print.scss hides every other <body> child. |
| Colored header band / tinted rows missing in the PDF | Browsers drop backgrounds by default; #invoice-print-root sets print-color-adjust: exact. Keep "Background graphics" on in the print dialog. |
| Dark theme prints dark | _print.scss re-declares the tokens to light values on the print root. If you add new colors, use tokens so the override applies. |
Totals show …000000001 artifacts | All math runs through the epsilon-safe round2. If you add a computation, round it the same way. |
| Logo / signature upload silently does nothing | Files over 1 MB are rejected with a danger toast (invoice:imageTooLarge). |
| Seed edits don't show up | The persisted invoice-state-v1 wins. Toolbar More → Reset, or "Reset to defaults", restores the seed. |
| The Excel export feels slow the first time | exceljs is lazy-loaded on first use — the chunk downloads once, then the export is instant. |
FAQ
Is the PDF generated client-side? Via the browser: window.print() opens the print dialog, and
"Save as PDF" produces the file. There is no PDF library — the isolated print stylesheet is the whole
trick.
Why a real .xlsx instead of CSV? The export mirrors the visual template (colored header band,
tinted headers, currency number formats). CSV can't be styled.
Does "Send to client" email anything? No — it's a presentational demo action (success toast). Wire
it to your API where the toast fires in useInvoice.
Where do drafts go? "Save as draft" persists to the same invoice-state-v1 key — the builder
holds one working invoice, not a document list.
Do amounts localize? Yes — formatMoney and the date formatting receive the active i18n.language.
Related
Forms
The Field / Input / Textarea / Select / DatePicker primitives the builder uses.
Components
The toolbar Dropdown, the Accordion, and the framing Panels.
Utility Pages
Cookie consent — a sibling Pages feature with the same data-module + hook idiom.
Architecture & Routing
The route registry, persistence, and Reset.
Design Tokens & Dark Mode
The token system the document (and its print exception) builds on.
Was this page helpful?
