PVR Tech Studio

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.

10 min read
Updated August 13, 2026

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

FileResponsibility
src/pages/InvoicePage.tsxThe 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.tsThe 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.tsxThe 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.tsxThe 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.tsTypes (InvoiceData, InvoiceLineItem, …), TEMPLATES / CURRENCIES / PAYMENT_METHODS option tables, formatMoney, lineAmount / computeTotals, nextItemId, the seed, and persistence (STORAGE_KEY, loadInvoice / saveInvoice / clearInvoice).
src/styles/_print.scssPrint 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'): string

formatMoney 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.tsx

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):

KeyTypeDescription
invoiceInvoiceDataThe live editable invoice.
totalsInvoiceTotalsMemoized {subtotal, taxAmount, discountAmount, total}.
localestringThe active i18n.language — pass it to formatMoney and date formatting.
open / setOpenstring[] / setterControlled accordion sections (defaults to ['my', 'details'] open).
setField(key, value) => voidTyped top-level field setter.
setCompany / setClient / setPayment(key, value) => voidNested-object field setters.
addItem / updateItem / removeItemline-item CRUDaddItem appends a blank row (quantity: 1, price: 0) with the next li-<n> id.
readImage(file, 'signatureDataUrl' | 'logoDataUrl') => voidReads an image into a data URL and stores it; rejects files over 1 MB with a danger toast.
save / saveDraft() => voidPersist immediately + success / info toast.
send() => voidDemo action — success toast only.
reset() => voidReplace with a fresh seedInvoice(), persist, info toast.
exportSheet() => Promise<void>Build + download a styled .xlsx via lazy-loaded exceljs (see below).
print() => voidwindow.print() — prints the portaled copy (see Print-to-PDF).

InvoiceForm / InvoicePreview

Both take a single prop:

PropTypeDescription
vmUseInvoiceThe view-model from useInvoice().

The three templates

invoice.template switches the document's look inside InvoicePreview (the body is shared; header and accents vary):

TemplateLook
modernFull-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.
classicPlain header, meta row framed border-y, standard table, total in text-primary.
minimalAirier padding, understated border-b header, total in text-foreground (no primary accent).

"Download PDF" is window.print() — the browser's print dialog does the PDF. Two pieces make the printout show only the invoice:

  1. A print-only copy on <body>. InvoicePage renders a second <InvoicePreview> through createPortal into <div id="invoice-print-root" className="hidden print:block"> on document.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 form Panel and preview toolbar are additionally print:hidden.
  2. src/styles/_print.scss. Under @media print it hides every other direct child of <body>, re-declares the raw token vars on #invoice-print-root to their light values (so the invoice always prints on white regardless of theme/skin), sets print-color-adjust: exact (so the template's colored backgrounds actually print), and a 14mm @page margin.

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.

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 through computeTotals. 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-root inside 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 localStorage key belongs in APP_LOCAL_KEYS (src/lib/appStorage.ts) so Reset clears it.

Troubleshooting

SymptomCause / fix
The printout shows the whole app, not just the invoiceThe 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 PDFBrowsers 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 darkIt 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 artifactsAll 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 nothingFiles 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 upThe persisted invoice-state-v1 wins. Toolbar More → Reset, or "Reset to defaults", restores the seed.
The Excel export feels slow the first timeexceljs 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.scss are 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.

Was this page helpful?