PVR Tech Studio

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.

8 min read
Updated July 15, 2026

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

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.
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, TEMPLATES / CURRENCIES / PAYMENT_METHODS option tables, formatMoney, lineAmount / computeTotals, nextItemId, the seed, and persistence.
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

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

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; 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.
print() => voidwindow.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.

"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, 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

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, in useInvoice.ts's exportSheet.

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.

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 through computeTotals. The rounding and locale handling are centralized.
  • Keep the print portal on <body>. Moving #invoice-print-root inside 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

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.
Colored header band / tinted rows missing in the PDFBrowsers 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 artifactsAll math runs through the epsilon-safe round2. 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).
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.

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.

Was this page helpful?