Invoice Builder
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 — they persist (send just toasts) rather than hit
an API.
All state and actions live in the useInvoice view-model hook; InvoicePage is layout + wiring
only. Labels resolve from the invoice i18n namespace at render; the seed content (a generic
"Northwind Studio → Acme Corporation" sample) is literal demo data.
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 (save / draft / send / export / print / reset). |
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 (InvoiceData, InvoiceLineItem, …), TEMPLATES / CURRENCIES / PAYMENT_METHODS option tables, formatMoney, lineAmount / computeTotals, nextItemId, the seed, and persistence (STORAGE_KEY, loadInvoice / saveInvoice / clearInvoice). |
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'): stringformatMoney is the repo's canonical currency formatter — the 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 (see Architecture & Routing — persistence & Reset). 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). The
route is registered as a standard in-shell page:
src/app/[locale]/(app)/invoice/page.tsxThe 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 and stores it; 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 (see below). |
print | () => void | window.print() — prints the portaled copy (see Print-to-PDF). |
InvoiceForm / InvoicePreview
Both take a single prop:
| Prop | Type | Description |
|---|---|---|
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):
| Template | Look |
|---|---|
modern | 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. |
classic | Plain header, meta row framed border-y, standard table, total in text-primary. |
minimal | Airier padding, understated border-b header, total in text-foreground (no primary accent). |
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(so the template's colored backgrounds actually print), and a14mm@pagemargin.
Info
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
Change the seed invoice
Edit seedInvoice() in src/data/invoice.ts — company, client, line items, tax/discount, payment,
notes, signature. Dates are generated relative to today (issue = today, due = +14 days) so the demo
always looks current.
Info
Existing browsers: the persisted invoice (invoice-state-v1) wins over the seed. Use the
toolbar's Reset (or "Reset to defaults") to see seed changes during development.
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
(never translated).
Add a payment method
Add a PAYMENT_METHODS entry (value + labelKey) and the label to the locale files:
{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, in useInvoice.ts's exportSheet.
Styling
The document is semantic-token styled (bg-surface, text-foreground, border-border, primary
accents), so it follows theme, dark mode, and skins live — see
Design Tokens & Dark Mode. The printout deliberately
ignores skins (always light — see above).
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)Add a line item from code
const {addItem, updateItem, invoice} = useInvoice()
addItem() // blank row: quantity 1, price 0, next li-<n> id
updateItem(invoice.items.at(-1)!.id, 'description', 'Hosting (12 months)')Best practices
- Keep logic in
useInvoice. Follow the feature-view-model convention: the page and form stay render-only; state, persistence, and the toolbar actions live in the hook. - All money through
formatMoney, all totals throughcomputeTotals. Don't re-implement currency formatting or hand-sum line items — the rounding and locale handling are centralized. - Keep the print portal on
<body>. Moving#invoice-print-rootinside the shell breaks print isolation (transforms/overflow clip it) and re-shows app chrome in the printout. - Store keys, not copy. Field labels, section titles, and toasts are i18n keys in
en/invoice.json(ja is seeded at parity); currency codes and the seed content stay literal. - Register new persisted keys. Any additional
localStoragekey belongs inAPP_LOCAL_KEYS(src/lib/appStorage.ts) so Reset clears it.
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. Don't move it into the shell. |
| Colored header band / tinted rows missing in the PDF | Browsers drop backgrounds by default; #invoice-print-root sets print-color-adjust: exact. Keep it (and leave "Background graphics" on in the print dialog). |
| Dark theme prints dark | It shouldn't — _print.scss re-declares the tokens to light values on the print root. If you add new colors to the document, use tokens so the override applies. |
Totals show …000000001 artifacts | All math runs through the epsilon-safe round2 in lineAmount/computeTotals. 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); images are stored as data URLs in the persisted state, so keep them small. |
| 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, and the HTML-as-.xls workaround
triggers Excel's "format doesn't match" warning.
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 with an info
toast — the builder holds one working invoice, not a document list.
Do amounts localize? Yes — formatMoney and the date formatting receive the active
i18n.language, so switching language reformats the document (labels come from the invoice
namespace).
Notes for designers & content editors
- All labels and messages (section titles, field labels, document strings like "Bill to" /
"Subtotal", toasts) live in
src/locales/<lng>/invoice.json. Edit copy there — never in the components. - Seed content (company, client, items, notes) is literal demo data in
src/data/invoice.ts— safe to replace wholesale. - Colors come from semantic tokens; the document re-skins and dark-modes automatically on screen.
The printout intentionally stays light — its hardcoded values in
src/styles/_print.scssare the one sanctioned exception to the tokens-only rule. - Templates: Modern is the default; the template picker sits in the preview toolbar and the choice persists with the invoice.
Related
Forms
the Field / Input / Textarea / Select / DatePicker primitives the builder uses
Overlays & Disclosure
the toolbar Dropdown and the Accordion
Panel
the portlets framing the form and preview
Cookie Consent
a sibling Pages-section 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
i18n
the invoice namespace and locale-aware formatting
Was this page helpful?
