Invoice UI for apps
Invoices in five screens. A printable sheet, an editor with live sums, a list with filters and bulk actions, a page to pay and reminders in stages.
From line items to money received
Invoices are documents people print, forward and check line by line, so every sum has to add up and every date has to read right. These five screens cover writing, sending, tracking, paying and reminding, with amounts and dates in the format of the page language.
Invoice document shows sender, recipient, line items, net, tax per rate and total, payment details and a paid stamp, with PDF download and a clean print of the sheet only. Invoice editor adds, moves and removes lines with quantity, price, tax rate and discount, sums update live, and checks run before sending. Invoice list shows sums for open, overdue and paid, status filters with counts, search, sortable columns and bulk mark as paid, cards on phones. Pay an invoice is the customer view with amount, due date and line items and card, direct debit or bank transfer with checked fields. Payment reminders sets stages by days overdue with a text with placeholders, an optional fee and a live email preview.
Put the amount due and the due date at the top of every invoice, that is what people look for first. Keep reminder texts friendly in the first stage, most late payments are simply forgotten. Tax rules differ by country, so pass rates and legal lines in from your own system.
Variants
Details
What Invoice brings along, read from the registry and the source.
- Category
- CRM and invoicing
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/invoice.tsx
- npm packages
- None
- Languages
- English and German, 143 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The InvoiceProps interface from components/ui/invoice.tsx, with the comments from the code.
variantoptionalInvoiceVariantdocumenteditorlistpayreminder
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
currencyoptionalstringISO currency code, e.g. EUR or USD
invoiceoptionalInvoiceDataThe invoice on screen (document, editor, pay, reminder)
invoicesoptionalInvoiceRow[]list: all invoices
taxRatesoptionalnumber[]editor: tax rates to pick from
customersoptionalInvoiceParty[]editor: customers to pick from
methodsoptional('card' | 'debit' | 'transfer')[]pay: the payment methods on offer
stagesoptionalReminderStage[]reminder: the steps in order
sentoptionalReminderSent[]reminder: what went out so far
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, send, pay, print and remind. Without it the block simulates the request.
Built-in texts
Invoice writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 143 texts
| Key | English | German |
|---|---|---|
| invoice | English Invoice | German Rechnung |
| number | English Invoice number | German Rechnungsnummer |
| issued | English Invoice date | German Rechnungsdatum |
| due | English Due date | German Fällig am |
| customerNo | English Customer no. | German Kundennr. |
| reference | English Reference | German Bezug |
| billTo | English Bill to | German Rechnung an |
| from | English From | German Von |
| pos | English No. | German Pos. |
| description | English Description | German Beschreibung |
| qty | English Qty | German Menge |
| unitPrice | English Unit price | German Einzelpreis |
| tax | English Tax | German USt. |
| amount | English Amount | German Betrag |
| net | English Net | German Netto |
| discount | English Discount {p} | German Rabatt {p} |
| taxOn | English Tax {p} on {base} | German USt. {p} auf {base} |
| total | English Total | German Gesamt |
| payTo | English Payment | German Zahlung |
| payText | English Please pay {amount} by {date}, quoting {number}. | German Bitte überweise {amount} bis zum {date} mit dem Verwendungszweck {number}. |
| holder | English Account holder | German Kontoinhaber |
| iban | English IBAN | German IBAN |
| bic | English BIC | German BIC |
| bank | English Bank | German Bank |
| vat | English VAT ID | German USt-IdNr. |
| draft | English Draft | German Entwurf |
| open | English Open | German Offen |
| overdue | English Overdue | German Überfällig |
| paid | English Paid | German Bezahlt |
| English Print | German Drucken | |
| download | English Download PDF | German PDF herunterladen |
| downloading | English Preparing PDF… | German PDF wird erstellt… |
| downloaded | English The PDF is ready in your downloads. | German Das PDF liegt in deinen Downloads. |
| markPaid | English Mark as paid | German Als bezahlt markieren |
| markedPaid | English {number} is marked as paid. | German {number} ist als bezahlt markiert. |
| reopen | English Mark as open | German Als offen markieren |
| paidOn | English Paid on {date} | German Bezahlt am {date} |
| actions | English Invoice actions | German Aktionen zur Rechnung |
| customer | English Customer | German Kunde |
| terms | English Payment terms | German Zahlungsziel |
| daysN | English {n} days | German {n} Tage |
| lines | English Line items | German Positionen |
| addLine | English Add line | German Position hinzufügen |
| moveUp | English Move line {n} up | German Position {n} nach oben |
| moveDown | English Move line {n} down | German Position {n} nach unten |
| removeLine | English Remove line {n} | German Position {n} entfernen |
| lineTotal | English Line total | German Summe der Position |
| reorder | English Alt and the arrow keys move a line | German Alt und die Pfeiltasten verschieben eine Position |
| discountPct | English Discount in % | German Rabatt in % |
| note | English Note on the invoice | German Hinweis auf der Rechnung |
| saveDraft | English Save draft | German Entwurf speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Draft saved. | German Entwurf gespeichert. |
| send | English Send invoice | German Rechnung senden |
| sending | English Sending… | German Wird gesendet… |
| sent | English Invoice {number} sent to {email}. | German Rechnung {number} an {email} gesendet. |
| edit | English Edit | German Bearbeiten |
| preview | English Preview | German Vorschau |
| mode | English Editor or preview | German Editor oder Vorschau |
| fix | English Please check {n} fields before sending. | German Bitte prüfe {n} Felder vor dem Senden. |
| fixOne | English Please check one field before sending. | German Bitte prüfe ein Feld vor dem Senden. |
| needDesc | English Describe the service. | German Beschreibe die Leistung. |
| needQty | English Enter a quantity above zero. | German Trag eine Menge über null ein. |
| needPrice | English Enter a price of zero or more. | German Trag einen Preis ab null ein. |
| needLine | English Add at least one line. | German Füge mindestens eine Position hinzu. |
| needNumber | English Give the invoice a number. | German Gib der Rechnung eine Nummer. |
| summary | English Summary | German Summen |
| invoices | English Invoices | German Rechnungen |
| all | English All | German Alle |
| search | English Search number or customer | German Nummer oder Kunde suchen |
| noMatch | English No invoice matches. Try another filter. | German Keine Rechnung passt. Versuch einen anderen Filter. |
| openSum | English Open | German Offen |
| overdueSum | English Overdue | German Überfällig |
| paidSum | English Paid this month | German Bezahlt diesen Monat |
| countN | English {n} invoices | German {n} Rechnungen |
| sortBy | English Sort by {col} | German Nach {col} sortieren |
| sort | English Sort | German Sortieren |
| newest | English Newest first | German Neueste zuerst |
| dueSoon | English Due first | German Fällige zuerst |
| highest | English Highest amount | German Höchster Betrag |
| select | English Select {number} | German {number} auswählen |
| selectAll | English Select all shown | German Alle gezeigten auswählen |
| selected | English {n} selected | German {n} ausgewählt |
| clear | English Clear selection | German Auswahl aufheben |
| bulkPaid | English Mark as paid | German Als bezahlt markieren |
| bulkDone | English {n} invoices marked as paid. | German {n} Rechnungen als bezahlt markiert. |
| remind | English Send reminder | German Erinnerung senden |
| reminded | English Reminder for {number} sent. | German Erinnerung zu {number} gesendet. |
| daysLate | English {n} days late | German seit {n} Tagen fällig |
| dueIn | English due in {n} days | German fällig in {n} Tagen |
| dueToday | English due today | German heute fällig |
| status | English Status | German Status |
| payInvoice | English Pay invoice | German Rechnung bezahlen |
| payFrom | English Invoice {number} from {name} | German Rechnung {number} von {name} |
| showItems | English Show line items | German Positionen zeigen |
| hideItems | English Hide line items | German Positionen ausblenden |
| method | English Payment method | German Zahlart |
| card | English Card | German Karte |
| debit | English Direct debit | German Lastschrift |
| transfer | English Bank transfer | German Überweisung |
| cardName | English Name on card | German Name auf der Karte |
| cardNumber | English Card number | German Kartennummer |
| expiry | English Expiry (MM/YY) | German Gültig bis (MM/JJ) |
| expiryHint | English MM/YY | German MM/JJ |
| cvc | English Security code | German Prüfnummer |
| badCard | English Check the card number. | German Prüf die Kartennummer. |
| badExpiry | English Enter a future date as MM/YY. | German Trag ein künftiges Datum als MM/JJ ein. |
| badCvc | English Enter the 3 or 4 digits on the back. | German Trag die 3 oder 4 Ziffern von der Rückseite ein. |
| badName | English Enter the name as on the card. | German Trag den Namen wie auf der Karte ein. |
| badIban | English Check the IBAN. | German Prüf die IBAN. |
| mandate | English I allow {name} to collect this amount from my account once. | German Ich erlaube {name}, diesen Betrag einmalig von meinem Konto einzuziehen. |
| needMandate | English Please agree to the direct debit. | German Bitte stimme der Lastschrift zu. |
| payNow | English Pay {amount} | German {amount} bezahlen |
| paying | English Processing payment… | German Zahlung läuft… |
| transferText | English Transfer the amount to this account. Use the invoice number as reference so we can match it. | German Überweise den Betrag auf dieses Konto. Gib die Rechnungsnummer als Verwendungszweck an, dann ordnen wir sie sofort zu. |
| copy | English Copy {what} | German {what} kopieren |
| copied | English {what} copied. | German {what} kopiert. |
| iTransferred | English I have sent the transfer | German Ich habe überwiesen |
| thanks | English Thank you, payment received | German Danke, die Zahlung ist da |
| thanksTransfer | English Thank you, we will confirm once the money arrives | German Danke, wir bestätigen, sobald das Geld eingeht |
| receipt | English Receipt {no} goes to {email}. | German Beleg {no} geht an {email}. |
| getReceipt | English Download receipt | German Beleg herunterladen |
| secure | English Encrypted connection. Card data never touches our server. | German Verschlüsselte Verbindung. Kartendaten erreichen nie unseren Server. |
| reminder | English Payment reminder | German Zahlungserinnerung |
| stagesLabel | English Reminder stage | German Mahnstufe |
| dayN | English Day {n} | German Tag {n} |
| sentOn | English Sent {date} | German Gesendet {date} |
| recommended | English Due now | German Jetzt dran |
| later | English Later | German Später |
| to | English To | German An |
| subject | English Subject | German Betreff |
| message | English Message | German Nachricht |
| addFee | English Add fee of {fee} | German Gebühr von {fee} hinzufügen |
| openAmount | English Open amount | German Offener Betrag |
| fee | English Fee | German Gebühr |
| newTotal | English New total | German Neuer Gesamtbetrag |
| sendReminder | English Send {stage} | German {stage} senden |
| reminderSent | English {stage} sent to {email}. | German {stage} an {email} gesendet. |
| history | English Sent so far | German Bisher gesendet |
| nothingSent | English No reminder has gone out yet. | German Bisher ging keine Erinnerung raus. |
| lateBy | English {n} days overdue | German seit {n} Tagen überfällig |
| emailPreview | English Email preview | German Vorschau der E-Mail |
| payButton | English Pay now | German Jetzt bezahlen |
Accessibility
Read from the source of the component.
- Invoice handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live and aria-sort, so screen readers announce purpose and state.
- Uses the roles alert, group, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,890 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollFade } from '@/components/ui/motion';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type InvoiceVariant = 'document' | 'editor' | 'list' | 'pay' | 'reminder';
export type InvoiceStatus = 'draft' | 'open' | 'overdue' | 'paid';
export interface InvoiceParty {
name: string;
/** Person to address, e.g. "Sophie Krüger" */
contact?: string;
street?: string;
/** Postcode and city */
city?: string;
country?: string;
email?: string;
/** VAT or tax number */
vatId?: string;
/** Customer number */
customerNo?: string;
}
export interface InvoiceItem {
id: string;
description: string;
detail?: string;
qty: number;
/** e.g. "h", "pcs", "Std.", "Stk." */
unit?: string;
/** Net price of one unit */
price: number;
/** Tax rate in percent, e.g. 19 */
tax: number;
}
export interface InvoiceBank {
holder: string;
iban: string;
bic?: string;
bank?: string;
}
export interface InvoiceData {
number: string;
/** ISO date */
issued: string;
/** ISO date */
due: string;
status?: InvoiceStatus;
from: InvoiceParty;
to: InvoiceParty;
items: InvoiceItem[];
/** Discount on the net sum, in percent */
discount?: number;
/** Closing line on the invoice, e.g. thanks or a project reference */
note?: string;
bank?: InvoiceBank;
/** Service period or order reference */
reference?: string;
}
export interface InvoiceRow {
id: string;
number: string;
customer: string;
/** ISO date */
issued: string;
/** ISO date */
due: string;
/** Gross amount */
amount: number;
status: InvoiceStatus;
}
export interface ReminderStage {
id: string;
label: string;
/** Days after the due date */
day: number;
/** Fee added to the open amount */
fee?: number;
subject: string;
/** Text with {name}, {number}, {amount}, {due}, {days}, {total} and {sender} */
body: string;
}
export interface ReminderSent {
stage: string;
/** ISO date and time */
time: string;
}
export interface InvoiceProps {
variant?: InvoiceVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title?: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** ISO currency code, e.g. EUR or USD */
currency?: string;
/** The invoice on screen (document, editor, pay, reminder) */
invoice?: InvoiceData;
/** list: all invoices */
invoices?: InvoiceRow[];
/** editor: tax rates to pick from */
taxRates?: number[];
/** editor: customers to pick from */
customers?: InvoiceParty[];
/** pay: the payment methods on offer */
methods?: ('card' | 'debit' | 'transfer')[];
/** reminder: the steps in order */
stages?: ReminderStage[];
/** reminder: what went out so far */
sent?: ReminderSent[];
/** Called on save, send, pay, print and remind. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
invoice: 'Invoice',
number: 'Invoice number',
issued: 'Invoice date',
due: 'Due date',
customerNo: 'Customer no.',
reference: 'Reference',
billTo: 'Bill to',
from: 'From',
pos: 'No.',
description: 'Description',
qty: 'Qty',
unitPrice: 'Unit price',
tax: 'Tax',
amount: 'Amount',
net: 'Net',
discount: 'Discount {p}',
taxOn: 'Tax {p} on {base}',
total: 'Total',
payTo: 'Payment',
payText: 'Please pay {amount} by {date}, quoting {number}.',
holder: 'Account holder',
iban: 'IBAN',
bic: 'BIC',
bank: 'Bank',
vat: 'VAT ID',
draft: 'Draft',
open: 'Open',
overdue: 'Overdue',
paid: 'Paid',
print: 'Print',
download: 'Download PDF',
downloading: 'Preparing PDF…',
downloaded: 'The PDF is ready in your downloads.',
markPaid: 'Mark as paid',
markedPaid: '{number} is marked as paid.',
reopen: 'Mark as open',
paidOn: 'Paid on {date}',
actions: 'Invoice actions',
customer: 'Customer',
terms: 'Payment terms',
daysN: '{n} days',
lines: 'Line items',
addLine: 'Add line',
moveUp: 'Move line {n} up',
moveDown: 'Move line {n} down',
removeLine: 'Remove line {n}',
lineTotal: 'Line total',
reorder: 'Alt and the arrow keys move a line',
discountPct: 'Discount in %',
note: 'Note on the invoice',
saveDraft: 'Save draft',
saving: 'Saving…',
saved: 'Draft saved.',
send: 'Send invoice',
sending: 'Sending…',
sent: 'Invoice {number} sent to {email}.',
edit: 'Edit',
preview: 'Preview',
mode: 'Editor or preview',
fix: 'Please check {n} fields before sending.',
fixOne: 'Please check one field before sending.',
needDesc: 'Describe the service.',
needQty: 'Enter a quantity above zero.',
needPrice: 'Enter a price of zero or more.',
needLine: 'Add at least one line.',
needNumber: 'Give the invoice a number.',
summary: 'Summary',
invoices: 'Invoices',
all: 'All',
search: 'Search number or customer',
noMatch: 'No invoice matches. Try another filter.',
openSum: 'Open',
overdueSum: 'Overdue',
paidSum: 'Paid this month',
countN: '{n} invoices',
sortBy: 'Sort by {col}',
sort: 'Sort',
newest: 'Newest first',
dueSoon: 'Due first',
highest: 'Highest amount',
select: 'Select {number}',
selectAll: 'Select all shown',
selected: '{n} selected',
clear: 'Clear selection',
bulkPaid: 'Mark as paid',
bulkDone: '{n} invoices marked as paid.',
remind: 'Send reminder',
reminded: 'Reminder for {number} sent.',
daysLate: '{n} days late',
dueIn: 'due in {n} days',
dueToday: 'due today',
status: 'Status',
payInvoice: 'Pay invoice',
payFrom: 'Invoice {number} from {name}',
showItems: 'Show line items',
hideItems: 'Hide line items',
method: 'Payment method',
card: 'Card',
debit: 'Direct debit',
transfer: 'Bank transfer',
cardName: 'Name on card',
cardNumber: 'Card number',
expiry: 'Expiry (MM/YY)',
expiryHint: 'MM/YY',
cvc: 'Security code',
badCard: 'Check the card number.',
badExpiry: 'Enter a future date as MM/YY.',
badCvc: 'Enter the 3 or 4 digits on the back.',
badName: 'Enter the name as on the card.',
badIban: 'Check the IBAN.',
mandate: 'I allow {name} to collect this amount from my account once.',
needMandate: 'Please agree to the direct debit.',
payNow: 'Pay {amount}',
paying: 'Processing payment…',
transferText: 'Transfer the amount to this account. Use the invoice number as reference so we can match it.',
copy: 'Copy {what}',
copied: '{what} copied.',
iTransferred: 'I have sent the transfer',
thanks: 'Thank you, payment received',
thanksTransfer: 'Thank you, we will confirm once the money arrives',
receipt: 'Receipt {no} goes to {email}.',
getReceipt: 'Download receipt',
secure: 'Encrypted connection. Card data never touches our server.',
reminder: 'Payment reminder',
stagesLabel: 'Reminder stage',
dayN: 'Day {n}',
sentOn: 'Sent {date}',
recommended: 'Due now',
later: 'Later',
to: 'To',
subject: 'Subject',
message: 'Message',
addFee: 'Add fee of {fee}',
openAmount: 'Open amount',
fee: 'Fee',
newTotal: 'New total',
sendReminder: 'Send {stage}',
reminderSent: '{stage} sent to {email}.',
history: 'Sent so far',
nothingSent: 'No reminder has gone out yet.',
lateBy: '{n} days overdue',
emailPreview: 'Email preview',
payButton: 'Pay now',
},
de: {
invoice: 'Rechnung',
number: 'Rechnungsnummer',
issued: 'Rechnungsdatum',
due: 'Fällig am',
customerNo: 'Kundennr.',
reference: 'Bezug',
billTo: 'Rechnung an',
from: 'Von',
pos: 'Pos.',
description: 'Beschreibung',
qty: 'Menge',
unitPrice: 'Einzelpreis',
tax: 'USt.',
amount: 'Betrag',
net: 'Netto',
discount: 'Rabatt {p}',
taxOn: 'USt. {p} auf {base}',
total: 'Gesamt',
payTo: 'Zahlung',
payText: 'Bitte überweise {amount} bis zum {date} mit dem Verwendungszweck {number}.',
holder: 'Kontoinhaber',
iban: 'IBAN',
bic: 'BIC',
bank: 'Bank',
vat: 'USt-IdNr.',
draft: 'Entwurf',
open: 'Offen',
overdue: 'Überfällig',
paid: 'Bezahlt',
print: 'Drucken',
download: 'PDF herunterladen',
downloading: 'PDF wird erstellt…',
downloaded: 'Das PDF liegt in deinen Downloads.',
markPaid: 'Als bezahlt markieren',
markedPaid: '{number} ist als bezahlt markiert.',
reopen: 'Als offen markieren',
paidOn: 'Bezahlt am {date}',
actions: 'Aktionen zur Rechnung',
customer: 'Kunde',
terms: 'Zahlungsziel',
daysN: '{n} Tage',
lines: 'Positionen',
addLine: 'Position hinzufügen',
moveUp: 'Position {n} nach oben',
moveDown: 'Position {n} nach unten',
removeLine: 'Position {n} entfernen',
lineTotal: 'Summe der Position',
reorder: 'Alt und die Pfeiltasten verschieben eine Position',
discountPct: 'Rabatt in %',
note: 'Hinweis auf der Rechnung',
saveDraft: 'Entwurf speichern',
saving: 'Wird gespeichert…',
saved: 'Entwurf gespeichert.',
send: 'Rechnung senden',
sending: 'Wird gesendet…',
sent: 'Rechnung {number} an {email} gesendet.',
edit: 'Bearbeiten',
preview: 'Vorschau',
mode: 'Editor oder Vorschau',
fix: 'Bitte prüfe {n} Felder vor dem Senden.',
fixOne: 'Bitte prüfe ein Feld vor dem Senden.',
needDesc: 'Beschreibe die Leistung.',
needQty: 'Trag eine Menge über null ein.',
needPrice: 'Trag einen Preis ab null ein.',
needLine: 'Füge mindestens eine Position hinzu.',
needNumber: 'Gib der Rechnung eine Nummer.',
summary: 'Summen',
invoices: 'Rechnungen',
all: 'Alle',
search: 'Nummer oder Kunde suchen',
noMatch: 'Keine Rechnung passt. Versuch einen anderen Filter.',
openSum: 'Offen',
overdueSum: 'Überfällig',
paidSum: 'Bezahlt diesen Monat',
countN: '{n} Rechnungen',
sortBy: 'Nach {col} sortieren',
sort: 'Sortieren',
newest: 'Neueste zuerst',
dueSoon: 'Fällige zuerst',
highest: 'Höchster Betrag',
select: '{number} auswählen',
selectAll: 'Alle gezeigten auswählen',
selected: '{n} ausgewählt',
clear: 'Auswahl aufheben',
bulkPaid: 'Als bezahlt markieren',
bulkDone: '{n} Rechnungen als bezahlt markiert.',
remind: 'Erinnerung senden',
reminded: 'Erinnerung zu {number} gesendet.',
daysLate: 'seit {n} Tagen fällig',
dueIn: 'fällig in {n} Tagen',
dueToday: 'heute fällig',
status: 'Status',
payInvoice: 'Rechnung bezahlen',
payFrom: 'Rechnung {number} von {name}',
showItems: 'Positionen zeigen',
hideItems: 'Positionen ausblenden',
method: 'Zahlart',
card: 'Karte',
debit: 'Lastschrift',
transfer: 'Überweisung',
cardName: 'Name auf der Karte',
cardNumber: 'Kartennummer',
expiry: 'Gültig bis (MM/JJ)',
expiryHint: 'MM/JJ',
cvc: 'Prüfnummer',
badCard: 'Prüf die Kartennummer.',
badExpiry: 'Trag ein künftiges Datum als MM/JJ ein.',
badCvc: 'Trag die 3 oder 4 Ziffern von der Rückseite ein.',
badName: 'Trag den Namen wie auf der Karte ein.',
badIban: 'Prüf die IBAN.',
mandate: 'Ich erlaube {name}, diesen Betrag einmalig von meinem Konto einzuziehen.',
needMandate: 'Bitte stimme der Lastschrift zu.',
payNow: '{amount} bezahlen',
paying: 'Zahlung läuft…',
transferText: 'Überweise den Betrag auf dieses Konto. Gib die Rechnungsnummer als Verwendungszweck an, dann ordnen wir sie sofort zu.',
copy: '{what} kopieren',
copied: '{what} kopiert.',
iTransferred: 'Ich habe überwiesen',
thanks: 'Danke, die Zahlung ist da',
thanksTransfer: 'Danke, wir bestätigen, sobald das Geld eingeht',
receipt: 'Beleg {no} geht an {email}.',
getReceipt: 'Beleg herunterladen',
secure: 'Verschlüsselte Verbindung. Kartendaten erreichen nie unseren Server.',
reminder: 'Zahlungserinnerung',
stagesLabel: 'Mahnstufe',
dayN: 'Tag {n}',
sentOn: 'Gesendet {date}',
recommended: 'Jetzt dran',
later: 'Später',
to: 'An',
subject: 'Betreff',
message: 'Nachricht',
addFee: 'Gebühr von {fee} hinzufügen',
openAmount: 'Offener Betrag',
fee: 'Gebühr',
newTotal: 'Neuer Gesamtbetrag',
sendReminder: '{stage} senden',
reminderSent: '{stage} an {email} gesendet.',
history: 'Bisher gesendet',
nothingSent: 'Bisher ging keine Erinnerung raus.',
lateBy: 'seit {n} Tagen überfällig',
emailPreview: 'Vorschau der E-Mail',
payButton: 'Jetzt bezahlen',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion and print */
/* ------------------------------------------------------------------ */
/*
* An area waits until it scrolls in, then its children rise (inv-up), pop
* (inv-pop) or grow (inv-grow) with --inv-d as delay. inv-in and inv-flash run
* when something changes. The server renders the end state, without JavaScript
* nothing waits longer than 2.5 s, with reduced motion nothing moves.
* Printing shows only the sheet [data-invoice-sheet], black on white.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const FX: [string, string, number][] = [
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.94)', 450],
['grow', 'transform:scaleX(0)', 900],
];
const CSS =
FX.map(([k, v]) => `@keyframes inv-${k}{from{${v}}}@keyframes inv-${k}-h{from{${v}}}`).join('') +
'@keyframes inv-flash{from{background-color:color-mix(in oklab,var(--primary) 12%,transparent)}}@keyframes inv-draw{from{stroke-dashoffset:60}}@keyframes inv-stamp{from{opacity:0;transform:rotate(-8deg) scale(1.4)}}.inv-grow{transform-origin:left}' +
'@media (prefers-reduced-motion:no-preference){' +
FX.map(([k, , d]) => `[data-inv=play] .inv-${k}{animation:inv-${k} ${d}ms var(--ease-kit) var(--inv-d,0ms) both}[data-inv=wait] .inv-${k}{animation:inv-${k}-h 0s 1s both paused}`).join('') +
'.inv-in{animation:inv-up .45s var(--ease-kit) var(--inv-d,0ms) both}.inv-flash{animation:inv-flash 1.8s ease-out both}.inv-draw{stroke-dasharray:60;animation:inv-draw .7s var(--ease-kit) .15s both}.inv-stamp{animation:inv-stamp .5s cubic-bezier(.3,1.4,.6,1) both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
FX.map(([k]) => `[data-inv-scope]:not([data-inv]) .inv-${k}{animation:inv-${k}-h 0s 2.5s both}`).join('') +
'}@media print{body *{visibility:hidden!important}[data-invoice-sheet],[data-invoice-sheet] *{visibility:visible!important;color:#111!important;border-color:#d4d4d8!important;background:transparent!important;box-shadow:none!important;animation:none!important}[data-invoice-sheet]{position:absolute;inset:0 auto auto 0;width:100%;max-width:none;margin:0;padding:0!important;border:0!important;background:#fff!important}[data-invoice-sheet] [data-print-hide]{display:none!important}}';
function Style() {
return (
<style href="kitb5-invoice" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-inv-scope': '', 'data-inv': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const DAY = 86400000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const dayOf = (iso: string) => Math.floor(toMs(iso) / DAY);
const isoDay = (d: number) => new Date(d * DAY).toISOString().slice(0, 10);
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const cents = (n: number) => Math.round(n * 100) / 100;
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: InvoiceProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
type Fmt = ReturnType<typeof makeFmt>;
function makeFmt(locale: Locale, currency: string) {
const tag = tagOf(locale);
const m2 = new Intl.NumberFormat(tag, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 });
const n2 = new Intl.NumberFormat(tag, { maximumFractionDigits: 2 });
const pct = (n: number) => (locale === 'de' ? `${n2.format(n)} %` : `${n2.format(n)}%`);
const date = (iso: string, o: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: 'numeric' }) => new Intl.DateTimeFormat(tag, { timeZone: 'UTC', ...(locale === 'en' && o.month === '2-digit' ? { month: 'short', day: 'numeric', year: 'numeric' } : o) }).format(new Date(toMs(iso)));
/** Reads "1.234,50" (de) or "1,234.50" (en) */
const parse = (s: string) => {
const x = s.trim().replace(/[^\d.,-]/g, '');
if (!x) return NaN;
const de = locale === 'de';
const clean = de ? x.replace(/\./g, '').replace(',', '.') : x.replace(/,/g, '');
return Number(clean);
};
return { money: (n: number) => m2.format(n), num: (n: number) => n2.format(n), pct, date, parse };
}
/** Net, discount, tax per rate and total, every amount rounded to cents */
function totals(items: InvoiceItem[], discount = 0) {
const net = cents(items.reduce((s, x) => s + (Number.isFinite(x.qty) ? x.qty : 0) * (Number.isFinite(x.price) ? x.price : 0), 0));
const off = cents((net * discount) / 100);
const byRate = new Map<number, number>();
items.forEach((x) => byRate.set(x.tax, (byRate.get(x.tax) ?? 0) + (Number.isFinite(x.qty) ? x.qty : 0) * (Number.isFinite(x.price) ? x.price : 0)));
const taxes = [...byRate.entries()]
.sort((a, b) => b[0] - a[0])
.map(([rate, base]) => {
const b = cents(base * (1 - discount / 100));
return { rate, base: b, tax: cents((b * rate) / 100) };
});
const total = cents(net - off + taxes.reduce((s, x) => s + x.tax, 0));
return { net, off, taxes, total };
}
type P = Omit<InvoiceProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale; f: Fmt; currency: string };
const focusRing = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive aria-[invalid=true]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_14%,transparent)] sm:h-9 sm:text-[13px]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30 sm:size-9', focusRing);
const cardCls = 'rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const labelCls = 'grid gap-1.5 text-[12.5px] font-medium text-muted-foreground';
function Header({ title, subtitle, children }: { title?: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
if (!title && !subtitle && !children) return null;
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
{title && <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn('relative inline-flex h-11 flex-1 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-colors duration-200 sm:h-8', focusRing, value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
const STATUS: Record<InvoiceStatus, { cls: string; dot: string }> = {
draft: { cls: 'bg-muted text-muted-foreground', dot: 'bg-subtle-foreground' },
open: { cls: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', dot: 'bg-primary' },
overdue: { cls: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive', dot: 'bg-destructive' },
paid: { cls: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground', dot: 'bg-success' },
};
function StatusBadge({ status, t, className }: { status: InvoiceStatus; t: Texts; className?: string }) {
return (
<span key={status} className={cn('inv-in inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium', STATUS[status].cls, className)}>
<span className={cn('size-1.5 rounded-full', STATUS[status].dot)} />
{t[status]}
</span>
);
}
function Address({ p, t }: { p: InvoiceParty; t: Texts }) {
return (
<address className="not-italic text-[13px] leading-relaxed text-muted-foreground">
<span className="block font-semibold text-foreground">{p.name}</span>
{p.contact && <span className="block">{p.contact}</span>}
{p.street && <span className="block">{p.street}</span>}
{p.city && <span className="block">{p.city}</span>}
{p.country && <span className="block">{p.country}</span>}
{p.vatId && (
<span className="mt-1 block text-[12px]">
{t.vat} {p.vatId}
</span>
)}
</address>
);
}
/** Net, discount, tax per rate and total as a right aligned list */
function Totals({ items, discount, t, f, big, className }: { items: InvoiceItem[]; discount?: number; t: Texts; f: Fmt; big?: boolean; className?: string }) {
const s = totals(items, discount);
return (
<dl className={cn('grid gap-1.5 text-[13.5px]', className)}>
<div className="flex justify-between gap-6">
<dt className="text-muted-foreground">{t.net}</dt>
<dd className="tabular-nums text-foreground">{f.money(s.net)}</dd>
</div>
{discount ? (
<div className="flex justify-between gap-6">
<dt className="text-muted-foreground">{fill(t.discount, { p: f.pct(discount) })}</dt>
<dd className="tabular-nums text-foreground">−{f.money(s.off)}</dd>
</div>
) : null}
{s.taxes.map((x) => (
<div key={x.rate} className="flex justify-between gap-6">
<dt className="text-muted-foreground">{fill(t.taxOn, { p: f.pct(x.rate), base: f.money(x.base) })}</dt>
<dd className="tabular-nums text-foreground">{f.money(x.tax)}</dd>
</div>
))}
<div className="mt-1.5 flex items-baseline justify-between gap-6 border-t border-border pt-2.5">
<dt className="font-semibold text-foreground">{t.total}</dt>
<dd key={s.total} className={cn('inv-in font-semibold tracking-[-0.02em] tabular-nums text-foreground', big ? 'text-[22px]' : 'text-[17px]')}>
{f.money(s.total)}
</dd>
</div>
</dl>
);
}
/** The invoice as a sheet of paper. Prints on its own. */
function Sheet({ inv, t, f, status, compact, className }: { inv: InvoiceData; t: Texts; f: Fmt; status?: InvoiceStatus; compact?: boolean; className?: string }) {
const s = totals(inv.items, inv.discount);
const meta = [
{ k: t.number, v: inv.number },
{ k: t.issued, v: f.date(inv.issued) },
{ k: t.due, v: f.date(inv.due) },
...(inv.to.customerNo ? [{ k: t.customerNo, v: inv.to.customerNo }] : []),
...(inv.reference ? [{ k: t.reference, v: inv.reference }] : []),
];
return (
<article data-invoice-sheet="" aria-label={`${t.invoice} ${inv.number}`} className={cn('relative mx-auto w-full rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]', compact ? 'p-5' : 'p-5 sm:p-10 lg:p-12', className)}>
{status === 'paid' && (
<span aria-hidden className={cn('inv-stamp absolute rounded-md border-2 border-success px-2.5 py-0.5 text-[13px] font-bold uppercase tracking-[0.12em] text-success-foreground rotate-[-8deg]', compact ? 'end-4 top-14' : 'end-5 top-24 sm:end-10 sm:top-12 lg:end-12')}>{t.paid}</span>
)}
<header className={cn('flex flex-col gap-6', !compact && 'sm:flex-row sm:items-start sm:justify-between')}>
<div className="flex items-center gap-3">
<span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-lg bg-primary text-[15px] font-bold text-primary-foreground">{inv.from.name.charAt(0)}</span>
<span className="text-[16px] font-semibold tracking-[-0.01em] text-foreground">{inv.from.name}</span>
</div>
{!compact && (
<p className="text-[12px] leading-relaxed text-muted-foreground sm:text-right">
{[inv.from.street, inv.from.city, inv.from.email].filter(Boolean).join(' · ')}
</p>
)}
</header>
<div className={cn('mt-8 grid grid-cols-[minmax(0,1fr)] gap-6', !compact && 'sm:mt-10 sm:grid-cols-[minmax(0,1fr)_auto]')}>
<div>
<p className="mb-1.5 text-[11.5px] font-semibold uppercase tracking-[0.08em] text-subtle-foreground">{t.billTo}</p>
<Address p={inv.to} t={t} />
</div>
<dl className="grid grid-cols-[auto_auto] content-start gap-x-6 gap-y-1 text-[13px] sm:justify-end">
{meta.map((m) => (
<div key={m.k} className="contents">
<dt className="text-muted-foreground">{m.k}</dt>
<dd className="text-right tabular-nums text-foreground">{m.v}</dd>
</div>
))}
</dl>
</div>
<h2 className={cn('mt-8 font-semibold tracking-[-0.025em] text-foreground', compact ? 'text-[19px]' : 'text-[22px] sm:mt-10 sm:text-[26px]')}>
{t.invoice} {inv.number}
</h2>
{/* Tablet, desktop and print: a table */}
<table className={cn('mt-5 w-full border-collapse text-[13px]', compact ? 'hidden' : 'hidden sm:table')}>
<thead>
<tr className="border-b border-border-strong text-left text-[12px] text-muted-foreground">
<th scope="col" className="w-10 py-2 pe-3 font-medium">{t.pos}</th>
<th scope="col" className="py-2 pe-3 font-medium">{t.description}</th>
<th scope="col" className="py-2 pe-3 text-right font-medium">{t.qty}</th>
<th scope="col" className="py-2 pe-3 text-right font-medium">{t.unitPrice}</th>
<th scope="col" className="py-2 pe-3 text-right font-medium">{t.tax}</th>
<th scope="col" className="py-2 text-right font-medium">{t.amount}</th>
</tr>
</thead>
<tbody>
{inv.items.map((x, i) => (
<tr key={x.id} className="border-b border-border align-top">
<td className="py-3 pe-3 tabular-nums text-subtle-foreground">{i + 1}</td>
<td className="py-3 pe-3">
<span className="block font-medium text-foreground">{x.description}</span>
{x.detail && <span className="mt-0.5 block text-[12.5px] text-muted-foreground">{x.detail}</span>}
</td>
<td className="whitespace-nowrap py-3 pe-3 text-right tabular-nums text-foreground">
{f.num(x.qty)} {x.unit}
</td>
<td className="whitespace-nowrap py-3 pe-3 text-right tabular-nums text-foreground">{f.money(x.price)}</td>
<td className="whitespace-nowrap py-3 pe-3 text-right tabular-nums text-muted-foreground">{f.pct(x.tax)}</td>
<td className="whitespace-nowrap py-3 text-right font-medium tabular-nums text-foreground">{f.money(cents(x.qty * x.price))}</td>
</tr>
))}
</tbody>
</table>
{/* Phones and small previews: one block per line */}
<ol className={cn('mt-5 border-t border-border-strong', !compact && 'sm:hidden')}>
{inv.items.map((x) => (
<li key={x.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-0.5 border-b border-border py-3 text-[13px]">
<span className="font-medium text-foreground">{x.description}</span>
<span className="text-right font-medium tabular-nums text-foreground">{f.money(cents(x.qty * x.price))}</span>
<span className="col-span-2 tabular-nums text-muted-foreground">
{f.num(x.qty)} {x.unit} × {f.money(x.price)} · {f.pct(x.tax)}
</span>
</li>
))}
</ol>
<div className={cn('mt-5 flex', !compact && 'sm:justify-end')}>
<Totals items={inv.items} discount={inv.discount} t={t} f={f} big={!compact} className={cn('w-full', !compact && 'sm:w-80')} />
</div>
{inv.bank && !compact && (
<section className="mt-10 grid gap-4 rounded-lg border border-border bg-surface p-4 text-[13px] sm:grid-cols-[minmax(0,1fr)_auto] sm:p-5">
<div>
<h3 className="font-semibold text-foreground">{t.payTo}</h3>
<p className="mt-1 text-pretty text-muted-foreground">{fill(t.payText, { amount: f.money(s.total), date: f.date(inv.due), number: inv.number })}</p>
</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-0.5 text-[12.5px]">
<dt className="text-muted-foreground">{t.holder}</dt>
<dd className="text-foreground">{inv.bank.holder}</dd>
<dt className="text-muted-foreground">{t.iban}</dt>
<dd className="break-all tabular-nums text-foreground">{inv.bank.iban}</dd>
{inv.bank.bic && (
<>
<dt className="text-muted-foreground">{t.bic}</dt>
<dd className="text-foreground">{inv.bank.bic}</dd>
</>
)}
</dl>
</section>
)}
{inv.note && !compact && <p className="mt-6 text-pretty text-[13px] text-muted-foreground">{inv.note}</p>}
</article>
);
}
/* ------------------------------------------------------------------ */
/* document */
/* ------------------------------------------------------------------ */
function DocumentView({ t, f, title, subtitle, invoice, now, onAction }: P) {
const [status, setStatus] = useState<InvoiceStatus>(invoice?.status ?? 'open');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
if (!invoice) return null;
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid gap-5">
<div role="group" aria-label={t.actions} className={cn(cardCls, 'inv-up mx-auto flex w-full max-w-[56rem] flex-wrap items-center gap-2 p-2 sm:p-2.5')}>
<span className="flex min-w-0 flex-1 items-center gap-2.5 ps-2">
<span className="truncate text-[14px] font-semibold text-foreground">{invoice.number}</span>
<StatusBadge status={status} t={t} />
</span>
<div className="grid w-full grid-cols-3 gap-2 sm:flex sm:w-auto">
<Button
size="sm"
variant="ghost"
iconLeft={busy ? 'loader' : 'download'}
disabled={busy}
onClick={async () => {
setBusy(true);
await run(onAction, 'download', { number: invoice.number }, 900);
setBusy(false);
setMsg(t.downloaded);
}}
>
<span className="max-sm:sr-only">{busy ? t.downloading : t.download}</span>
<span className="sm:hidden">PDF</span>
</Button>
<Button
size="sm"
variant="ghost"
iconLeft="printer"
onClick={() => {
void onAction?.('print', { number: invoice.number });
window.print();
}}
>
{t.print}
</Button>
<Button
size="sm"
variant={status === 'paid' ? 'outline' : 'primary'}
iconLeft={status === 'paid' ? 'rotate-ccw' : 'check'}
onClick={() => {
const next = status === 'paid' ? (dayOf(invoice.due) < dayOf(now) ? 'overdue' : 'open') : 'paid';
setStatus(next);
setMsg(next === 'paid' ? fill(t.markedPaid, { number: invoice.number }) : t[next]);
void run(onAction, 'status', { number: invoice.number, status: next });
}}
>
<span className="max-sm:sr-only">{status === 'paid' ? t.reopen : t.markPaid}</span>
<span className="sm:hidden">{status === 'paid' ? t.open : t.paid}</span>
</Button>
</div>
</div>
{busy && (
<div aria-hidden className="mx-auto h-0.5 w-full max-w-[56rem] overflow-hidden rounded-full bg-track">
<span className="inv-grow block h-full w-full bg-primary [--inv-d:0ms]" style={{ animation: prefersReducedMotion() ? undefined : 'inv-grow .9s var(--ease-kit) both' }} />
</div>
)}
<div className="inv-up [--inv-d:120ms]">
<Sheet inv={invoice} t={t} f={f} status={status} className="max-w-[56rem]" />
</div>
{status === 'paid' && (
<p className="inv-in mx-auto flex w-full max-w-[56rem] items-center gap-2 text-[13px] text-success-foreground">
<Icon name="check-circle" size={15} />
{fill(t.paidOn, { date: f.date(now.slice(0, 10)) })}
</p>
)}
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* editor */
/* ------------------------------------------------------------------ */
type Line = { id: string; description: string; qty: string; price: string; tax: number; unit?: string };
function Editor({ t, f, title, subtitle, invoice, customers = [], taxRates = [19, 7, 0], now, onAction }: P) {
const id = useId();
const start = invoice;
const [num, setNum] = useState(start?.number ?? '');
const [cust, setCust] = useState(Math.max(0, customers.findIndex((c) => c.name === start?.to.name)));
const [issued, setIssued] = useState(start?.issued ?? now.slice(0, 10));
const [term, setTerm] = useState(start ? Math.max(0, dayOf(start.due) - dayOf(start.issued)) : 14);
const [lines, setLines] = useState<Line[]>(() => (start?.items ?? []).map((x) => ({ id: x.id, description: x.description, qty: f.num(x.qty), price: f.num(x.price), tax: x.tax, unit: x.unit })));
const [discount, setDiscount] = useState(start?.discount ? f.num(start.discount) : '');
const [note, setNote] = useState(start?.note ?? '');
const [tried, setTried] = useState(false);
const [state, setState] = useState<'idle' | 'saving' | 'sending' | 'saved' | 'sent'>('idle');
const [mode, setMode] = useState<'edit' | 'preview'>('edit');
const [fresh, setFresh] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const listRef = useRef<HTMLOListElement>(null);
const customer = customers[cust] ?? start?.to ?? { name: '' };
const items: InvoiceItem[] = lines.map((l) => ({ id: l.id, description: l.description, qty: f.parse(l.qty), price: f.parse(l.price), tax: l.tax, unit: l.unit }));
const disc = Math.max(0, Math.min(100, f.parse(discount) || 0));
const errors = useMemo(() => {
const e: Record<string, string> = {};
if (!num.trim()) e.number = t.needNumber;
if (!lines.length) e.lines = t.needLine;
items.forEach((x) => {
if (!x.description.trim()) e[`${x.id}-d`] = t.needDesc;
if (!(x.qty > 0)) e[`${x.id}-q`] = t.needQty;
if (!(x.price >= 0)) e[`${x.id}-p`] = t.needPrice;
});
return e;
}, [num, lines, items, t]);
const err = (k: string) => (tried ? errors[k] : undefined);
const nErr = Object.keys(errors).length;
const due = isoDay(dayOf(issued || now.slice(0, 10)) + term);
const data: InvoiceData = { number: num, issued: issued || now.slice(0, 10), due, status: 'draft', from: start?.from ?? { name: '' }, to: customer, items: items.map((x) => ({ ...x, qty: x.qty || 0, price: x.price || 0 })), discount: disc, note, bank: start?.bank, reference: start?.reference };
const patch = (lid: string, p: Partial<Line>) => {
setLines((l) => l.map((x) => (x.id === lid ? { ...x, ...p } : x)));
setState('idle');
};
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= lines.length) return;
setLines((l) => {
const c = [...l];
[c[i], c[j]] = [c[j], c[i]];
return c;
});
setFresh(lines[i].id);
setMsg(fill(dir < 0 ? t.moveUp : t.moveDown, { n: i + 1 }));
requestAnimationFrame(() => listRef.current?.querySelectorAll<HTMLElement>('[data-line]')[j]?.querySelector<HTMLElement>('[data-handle]')?.focus());
};
const add = () => {
const l: Line = { id: `l${Date.now()}`, description: '', qty: '1', price: '', tax: taxRates[0] ?? 0, unit: lines.at(-1)?.unit };
setLines((x) => [...x, l]);
setFresh(l.id);
requestAnimationFrame(() => document.getElementById(`${id}-${l.id}-d`)?.focus());
};
const send = async () => {
setTried(true);
if (nErr) {
setMode('edit');
setMsg(nErr === 1 ? t.fixOne : fill(t.fix, { n: nErr }));
const first = Object.keys(errors)[0];
requestAnimationFrame(() => document.getElementById(first === 'number' ? `${id}-num` : first === 'lines' ? `${id}-add` : `${id}-${first}`)?.focus());
return;
}
setState('sending');
await run(onAction, 'send', { invoice: data }, 900);
setState('sent');
setMsg(fill(t.sent, { number: num, email: customer.email ?? customer.name }));
};
const form = (
<div className="grid min-w-0 gap-5">
<section className={cn(cardCls, 'inv-up p-4 sm:p-5')}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2">
<label className={labelCls}>
{t.customer}
<span className="relative block">
<select value={cust} onChange={(e) => setCust(Number(e.target.value))} className={cn(field, 'cursor-pointer appearance-none pe-8')}>
{customers.map((c, i) => (
<option key={c.name} value={i}>
{c.name}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
<label className={labelCls} htmlFor={`${id}-num`}>
{t.number}
<input id={`${id}-num`} value={num} aria-invalid={!!err('number')} aria-describedby={err('number') ? `${id}-num-e` : undefined} onChange={(e) => setNum(e.target.value)} className={cn(field, 'tabular-nums')} />
{err('number') && <span id={`${id}-num-e`} className="text-[12.5px] font-normal text-destructive">{err('number')}</span>}
</label>
<label className={labelCls}>
{t.issued}
<input type="date" value={issued} onChange={(e) => setIssued(e.target.value)} className={cn(field, 'tabular-nums')} />
</label>
<label className={labelCls}>
{t.terms}
<span className="relative block">
<select value={term} onChange={(e) => setTerm(Number(e.target.value))} className={cn(field, 'cursor-pointer appearance-none pe-8')}>
{[7, 14, 30].map((d) => (
<option key={d} value={d}>
{fill(t.daysN, { n: d })} · {f.date(isoDay(dayOf(issued || now.slice(0, 10)) + d))}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
</div>
</section>
<section aria-labelledby={`${id}-lines`} className={cn(cardCls, 'inv-up p-4 [--inv-d:80ms] sm:p-5')}>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 id={`${id}-lines`} className="text-[14px] font-semibold text-foreground">
{t.lines}
</h2>
<p id={`${id}-hint`} className="text-[12px] text-subtle-foreground max-sm:hidden">
{t.reorder}
</p>
</div>
<ol ref={listRef} className="mt-2 grid gap-2">
{lines.map((l, i) => {
const it = items[i];
const sum = (it.qty || 0) * (it.price || 0);
return (
<li key={l.id} data-line className={cn('grid grid-cols-[2.75rem_minmax(0,1fr)_auto] items-start gap-2 sm:grid-cols-[2.25rem_minmax(0,1fr)_auto] rounded-lg border border-border bg-surface p-2 transition-colors hover:border-border-strong', l.id === fresh && 'inv-in inv-flash')}>
<button
type="button"
data-handle
aria-label={`${t.pos} ${i + 1}. ${t.reorder}`}
aria-describedby={`${id}-hint`}
onKeyDown={(e) => {
if (e.altKey && e.key === 'ArrowUp') (e.preventDefault(), move(i, -1));
if (e.altKey && e.key === 'ArrowDown') (e.preventDefault(), move(i, 1));
}}
className={cn('grid h-11 w-11 cursor-grab place-items-center sm:w-9 rounded-md text-[12px] font-semibold tabular-nums text-muted-foreground hover:bg-accent sm:h-9', focusRing)}
>
{i + 1}
</button>
<div className="grid min-w-0 gap-1">
<input id={`${id}-${l.id}-d`} aria-label={`${t.description}, ${t.pos} ${i + 1}`} placeholder={t.description} value={l.description} aria-invalid={!!err(`${l.id}-d`)} onChange={(e) => patch(l.id, { description: e.target.value })} className={field} />
{err(`${l.id}-d`) && <span className="text-[12px] text-destructive">{err(`${l.id}-d`)}</span>}
</div>
{/* Phones and tablets: the line actions sit top right, desktop: at the end */}
<span className="flex">
<button type="button" aria-label={fill(t.moveUp, { n: i + 1 })} disabled={i === 0} onClick={() => move(i, -1)} className={cn(iconBtn, 'max-sm:hidden')}>
<Icon name="arrow-up" size={15} />
</button>
<button type="button" aria-label={fill(t.moveDown, { n: i + 1 })} disabled={i === lines.length - 1} onClick={() => move(i, 1)} className={cn(iconBtn, 'max-sm:hidden')}>
<Icon name="arrow-down" size={15} />
</button>
<button
type="button"
aria-label={fill(t.removeLine, { n: i + 1 })}
onClick={() => {
setLines((x) => x.filter((y) => y.id !== l.id));
setMsg(fill(t.removeLine, { n: i + 1 }));
}}
className={cn(iconBtn, 'hover:text-destructive')}
>
<Icon name="trash" size={15} />
</button>
</span>
<div className="col-span-3 grid grid-cols-3 gap-2 ps-[3.25rem] sm:ps-[2.75rem] sm:grid-cols-[6rem_minmax(0,1fr)_6.5rem_minmax(0,1fr)] sm:items-end">
<label className="grid gap-1 text-[11.5px] text-muted-foreground">
<span>{t.qty}</span>
<input inputMode="decimal" value={l.qty} aria-invalid={!!err(`${l.id}-q`)} id={`${id}-${l.id}-q`} onChange={(e) => patch(l.id, { qty: e.target.value })} onBlur={() => Number.isFinite(f.parse(l.qty)) && patch(l.id, { qty: f.num(f.parse(l.qty)) })} className={cn(field, 'text-right tabular-nums')} />
</label>
<label className="grid gap-1 text-[11.5px] text-muted-foreground">
<span>{t.unitPrice}</span>
<input inputMode="decimal" value={l.price} aria-invalid={!!err(`${l.id}-p`)} id={`${id}-${l.id}-p`} onChange={(e) => patch(l.id, { price: e.target.value })} onBlur={() => Number.isFinite(f.parse(l.price)) && patch(l.id, { price: f.num(f.parse(l.price)) })} className={cn(field, 'text-right tabular-nums')} />
</label>
<label className="grid gap-1 text-[11.5px] text-muted-foreground">
<span>{t.tax}</span>
<span className="relative block">
<select value={l.tax} onChange={(e) => patch(l.id, { tax: Number(e.target.value) })} className={cn(field, 'cursor-pointer appearance-none pe-7 tabular-nums')}>
{taxRates.map((r) => (
<option key={r} value={r}>
{f.pct(r)}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={13} className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
<p className="col-span-3 flex items-baseline justify-between text-[12.5px] text-muted-foreground sm:col-span-1 sm:grid sm:h-full sm:content-between sm:justify-items-end sm:text-right">
<span>{t.lineTotal}</span>
<span className="font-medium tabular-nums text-foreground">{f.money(cents(sum))}</span>
</p>
</div>
{(err(`${l.id}-q`) || err(`${l.id}-p`)) && <p className="col-span-3 ps-[3.25rem] sm:ps-[2.75rem] text-[12px] text-destructive">{[err(`${l.id}-q`), err(`${l.id}-p`)].filter(Boolean).join(' ')}</p>}
</li>
);
})}
</ol>
{err('lines') && <p className="mt-2 text-[13px] text-destructive">{err('lines')}</p>}
<Button id={`${id}-add`} variant="ghost" size="sm" iconLeft="plus" className="mt-2" onClick={add}>
{t.addLine}
</Button>
</section>
<section className={cn(cardCls, 'inv-up grid grid-cols-[minmax(0,1fr)] gap-3 p-4 [--inv-d:140ms] sm:grid-cols-[10rem_minmax(0,1fr)] sm:p-5')}>
<label className={labelCls}>
{t.discountPct}
<input inputMode="decimal" value={discount} onChange={(e) => setDiscount(e.target.value)} placeholder="0" className={cn(field, 'tabular-nums')} />
</label>
<label className={labelCls}>
{t.note}
<input value={note} onChange={(e) => setNote(e.target.value)} className={field} />
</label>
</section>
</div>
);
const side = (
<aside className="grid content-start gap-4 xl:sticky xl:top-6">
<section aria-label={t.summary} className={cn(cardCls, 'inv-up p-4 [--inv-d:100ms] sm:p-5')}>
<h2 className="text-[13px] font-semibold text-foreground">{t.summary}</h2>
<Totals items={data.items} discount={disc} t={t} f={f} big className="mt-3" />
{tried && nErr > 0 && (
<p role="alert" className="inv-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_10%,var(--card))] p-3 text-[13px] text-destructive">
<Icon name="alert-circle" size={15} className="mt-px shrink-0" />
{nErr === 1 ? t.fixOne : fill(t.fix, { n: nErr })}
</p>
)}
{state === 'sent' && (
<p className="inv-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{fill(t.sent, { number: num, email: customer.email ?? customer.name })}
</p>
)}
{state === 'saved' && (
<p className="inv-in mt-4 flex items-center gap-2 text-[13px] text-success-foreground">
<Icon name="check" size={15} />
{t.saved}
</p>
)}
<div className="mt-5 grid gap-2">
<Button iconLeft={state === 'sending' ? 'loader' : 'send'} disabled={state === 'sending'} onClick={() => void send()} magnetic className="w-full">
{state === 'sending' ? t.sending : t.send}
</Button>
<Button
variant="outline"
iconLeft={state === 'saving' ? 'loader' : 'file-check'}
disabled={state === 'saving'}
onClick={async () => {
setState('saving');
await run(onAction, 'save-draft', { invoice: data });
setState('saved');
setMsg(t.saved);
}}
>
{state === 'saving' ? t.saving : t.saveDraft}
</Button>
</div>
</section>
<div className="inv-up hidden [--inv-d:180ms] xl:block">
<p className="mb-2 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t.preview}</p>
<Sheet inv={data} t={t} f={f} compact />
</div>
</aside>
);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented label={t.mode} value={mode} onChange={setMode} className="w-full sm:w-auto xl:hidden" options={[{ value: 'edit', label: <><Icon name="edit" size={14} />{t.edit}</> }, { value: 'preview', label: <><Icon name="eye" size={14} />{t.preview}</> }]} />
</Header>
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
<div className={cn('min-w-0 xl:block', mode === 'edit' ? 'block' : 'hidden')}>{form}</div>
{mode === 'preview' && (
<div className="inv-in xl:hidden">
<Sheet inv={data} t={t} f={f} />
</div>
)}
{side}
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* list */
/* ------------------------------------------------------------------ */
type SortKey = 'number' | 'customer' | 'due' | 'amount';
function List({ t, f, title, subtitle, invoices = [], now, onAction }: P) {
const id = useId();
const today = dayOf(now);
// Open invoices past their date count as overdue
const [rows, setRows] = useState(() => invoices.map((r) => (r.status === 'open' && dayOf(r.due) < today ? { ...r, status: 'overdue' as const } : r)));
const [filter, setFilter] = useState<'all' | InvoiceStatus>('all');
const [q, setQ] = useState('');
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'due', dir: 1 });
const [sel, setSel] = useState<string[]>([]);
const [done, setDone] = useState<string[]>([]);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const [row, fade] = useScrollFade<HTMLDivElement>();
const filterRef = useGlide<HTMLDivElement>(filter);
const shown = useMemo(() => {
const ql = q.toLowerCase();
const l = rows.filter((r) => (filter === 'all' || r.status === filter) && (!q || r.number.toLowerCase().includes(ql) || r.customer.toLowerCase().includes(ql)));
const v = (r: InvoiceRow) => (sort.key === 'amount' ? r.amount : sort.key === 'due' ? toMs(r.due) : r[sort.key].toLowerCase());
return [...l].sort((a, b) => (v(a) > v(b) ? 1 : v(a) < v(b) ? -1 : 0) * sort.dir);
}, [rows, filter, q, sort]);
const sum = (s: InvoiceStatus) => rows.filter((r) => r.status === s).reduce((a, r) => a + r.amount, 0);
const month = now.slice(0, 7);
const paidMonth = rows.filter((r) => r.status === 'paid' && (done.includes(r.id) || r.due.slice(0, 7) === month)).reduce((a, r) => a + r.amount, 0);
const statuses: ('all' | InvoiceStatus)[] = ['all', 'open', 'overdue', 'paid', 'draft'];
const pay = (ids: string[]) => {
setRows((l) => l.map((r) => (ids.includes(r.id) ? { ...r, status: 'paid' } : r)));
setDone((d) => [...d, ...ids]);
setSel([]);
setMsg(fill(t.bulkDone, { n: ids.length }));
void run(onAction, 'mark-paid', { ids });
};
const remind = (r: InvoiceRow) => {
setMsg(fill(t.reminded, { number: r.number }));
setDone((d) => [...d, `rem-${r.id}`]);
void run(onAction, 'remind', { id: r.id });
};
const when = (r: InvoiceRow) => {
const d = dayOf(r.due) - today;
if (r.status === 'paid' || r.status === 'draft') return f.date(r.due);
if (d < 0) return fill(t.daysLate, { n: -d });
if (d === 0) return t.dueToday;
return fill(t.dueIn, { n: d });
};
const head = (key: SortKey, label: string, right?: boolean, extra?: string) => {
const on = sort.key === key;
return (
<th scope="col" aria-sort={on ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'} className={cn('px-3 py-0 font-medium', right && 'text-right', extra)}>
<button type="button" onClick={() => setSort({ key, dir: on ? (sort.dir === 1 ? -1 : 1) : key === 'amount' ? -1 : 1 })} className={cn('group inline-flex h-10 cursor-pointer items-center gap-1 rounded-sm text-[12px] transition-colors hover:text-foreground', focusRing, on ? 'text-foreground' : 'text-muted-foreground', right && 'flex-row-reverse')}>
{label}
<Icon name={on ? (sort.dir === 1 ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'} size={13} className={cn('transition-opacity', on ? 'opacity-100' : 'opacity-40 group-hover:opacity-100')} />
</button>
</th>
);
};
const allSel = shown.length > 0 && shown.every((r) => sel.includes(r.id));
const box = 'size-[18px] cursor-pointer rounded-[5px] border-border-strong accent-[var(--primary)]';
const action = (r: InvoiceRow, wide?: boolean) =>
r.status === 'overdue' ? (
done.includes(`rem-${r.id}`) ? (
<span className="inv-in inline-flex h-9 items-center gap-1 text-[12.5px] text-success-foreground">
<Icon name="check" size={14} />
{t.remind}
</span>
) : (
<Button size="sm" variant="outline" iconLeft="bell" onClick={() => remind(r)} className={wide ? 'w-full' : undefined}>
{t.remind}
</Button>
)
) : r.status === 'open' ? (
<Button size="sm" variant="ghost" iconLeft="check" onClick={() => pay([r.id])} className={wide ? 'w-full' : undefined}>
{t.bulkPaid}
</Button>
) : null;
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid gap-5">
<ul className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3">
{[
{ k: t.openSum, v: sum('open'), n: rows.filter((r) => r.status === 'open').length, tone: 'bg-primary' },
{ k: t.overdueSum, v: sum('overdue'), n: rows.filter((r) => r.status === 'overdue').length, tone: 'bg-destructive' },
{ k: t.paidSum, v: paidMonth, n: undefined, tone: 'bg-success' },
].map((x, i) => (
<li key={x.k} className="bg-card px-4 py-3.5 sm:px-5 sm:py-4">
<div className="inv-up flex items-center justify-between gap-3 sm:block" style={{ '--inv-d': `${i * 70}ms` } as React.CSSProperties}>
<p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<span className={cn('size-2 rounded-full', x.tone)} />
{x.k}
</p>
<p className="text-right sm:mt-1 sm:text-left">
<span key={x.v} className="inv-in text-[18px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[22px]">
{f.money(x.v)}
</span>
{x.n !== undefined && <span className="ms-2 text-[12.5px] tabular-nums text-subtle-foreground">{fill(t.countN, { n: x.n })}</span>}
</p>
</div>
</li>
))}
</ul>
<section aria-label={t.invoices} className={cn(cardCls, 'inv-up overflow-hidden [--inv-d:150ms]')}>
<div className="flex flex-col gap-3 border-b border-border p-3 sm:p-4 lg:flex-row lg:items-center lg:justify-between">
<div ref={row} className={cn('kit-no-scrollbar -mx-3 overflow-x-auto px-3 sm:mx-0 sm:px-0', fade)}>
<div
ref={filterRef}
role="radiogroup"
aria-label={t.status}
className="relative inline-flex rounded-lg bg-muted p-1"
onKeyDown={(e) => {
const dir = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const n = statuses[(statuses.indexOf(filter) + dir + statuses.length) % statuses.length];
setFilter(n);
const el = filterRef.current?.querySelector<HTMLButtonElement>(`[data-s=${n}]`);
el?.focus();
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}}
>
{statuses.map((s) => {
const on = filter === s;
const n = s === 'all' ? rows.length : rows.filter((r) => r.status === s).length;
return (
<button key={s} data-s={s} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => setFilter(s)} className={cn('relative inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-colors sm:h-8', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
{on && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative">{s === 'all' ? t.all : t[s]}</span>
<span className="relative text-[12px] tabular-nums text-subtle-foreground">{n}</span>
</button>
);
})}
</div>
</div>
<div className="flex gap-2">
<label className="relative block min-w-0 flex-1 lg:w-72">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
</label>
{/* Phones: sorting as a select, the table heads sort on larger screens */}
<label className="relative block w-36 shrink-0 sm:hidden">
<span className="sr-only">{t.sort}</span>
<select
value={`${sort.key}:${sort.dir}`}
onChange={(e) => {
const [k, d] = e.target.value.split(':');
setSort({ key: k as SortKey, dir: Number(d) as 1 | -1 });
}}
className={cn(field, 'cursor-pointer appearance-none pe-8')}
>
<option value="due:1">{t.dueSoon}</option>
<option value="number:-1">{t.newest}</option>
<option value="amount:-1">{t.highest}</option>
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</label>
</div>
</div>
{sel.length > 0 && (
<div className="inv-in flex flex-wrap items-center justify-between gap-2 border-b border-border bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] px-4 py-2">
<span className="text-[13px] font-medium text-foreground">{fill(t.selected, { n: sel.length })}</span>
<span className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => setSel([])}>
{t.clear}
</Button>
<Button size="sm" iconLeft="check" onClick={() => pay(sel.filter((x) => rows.find((r) => r.id === x)?.status !== 'paid'))}>
{t.bulkPaid}
</Button>
</span>
</div>
)}
{shown.length ? (
<>
<div className="kit-scrollbar relative hidden overflow-x-auto sm:block">
<table className="w-full border-collapse text-[13.5px]">
<caption className="sr-only">{t.invoices}</caption>
<thead>
<tr className="border-b border-border bg-surface text-left">
<th scope="col" className="w-12 ps-4">
<input type="checkbox" aria-label={t.selectAll} checked={allSel} onChange={() => setSel(allSel ? [] : shown.map((r) => r.id))} className={box} />
</th>
{head('number', t.number)}
{head('customer', t.customer)}
{head('due', t.due, false, 'max-lg:hidden')}
{head('amount', t.amount, true)}
<th scope="col" className="px-3 text-[12px] font-medium text-muted-foreground">
{t.status}
</th>
<th scope="col" className="w-1 pe-4">
<span className="sr-only">{t.actions}</span>
</th>
</tr>
</thead>
<tbody key={filter + sort.key + sort.dir}>
{shown.map((r, i) => (
<tr key={r.id} className={cn('inv-in border-b border-border transition-colors last:border-b-0 hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]', sel.includes(r.id) && 'bg-[color-mix(in_oklab,var(--primary)_5%,transparent)]')} style={{ '--inv-d': `${Math.min(i, 8) * 35}ms` } as React.CSSProperties}>
<td className="ps-4">
<input type="checkbox" aria-label={fill(t.select, { number: r.number })} checked={sel.includes(r.id)} onChange={() => setSel((s) => (s.includes(r.id) ? s.filter((x) => x !== r.id) : [...s, r.id]))} className={box} />
</td>
<td className="px-3 py-3 font-medium tabular-nums text-foreground">{r.number}</td>
<td className="max-w-[14rem] truncate px-3 py-3 text-foreground">
{r.customer}
<span className={cn('block truncate text-[12px] tabular-nums lg:hidden', r.status === 'overdue' ? 'text-destructive' : 'text-muted-foreground')}>{when(r)}</span>
</td>
<td className={cn('whitespace-nowrap px-3 py-3 tabular-nums max-lg:hidden', r.status === 'overdue' ? 'text-destructive' : 'text-muted-foreground')}>{when(r)}</td>
<td className="whitespace-nowrap px-3 py-3 text-right font-medium tabular-nums text-foreground">{f.money(r.amount)}</td>
<td className="px-3 py-3">
<StatusBadge status={r.status} t={t} />
</td>
<td className="whitespace-nowrap py-2 pe-3 text-right">{action(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Phones: one card per invoice */}
<ul key={filter + sort.key} className="grid gap-px bg-border sm:hidden">
{shown.map((r, i) => (
<li key={r.id} className="inv-in grid gap-2 bg-card p-4" style={{ '--inv-d': `${Math.min(i, 6) * 40}ms` } as React.CSSProperties}>
<div className="flex items-start gap-3">
<label className="relative z-[1] -m-[13px] grid size-11 shrink-0 cursor-pointer place-items-center">
<input type="checkbox" id={`${id}-m-${r.id}`} aria-label={fill(t.select, { number: r.number })} checked={sel.includes(r.id)} onChange={() => setSel((s) => (s.includes(r.id) ? s.filter((x) => x !== r.id) : [...s, r.id]))} className={box} />
</label>
<div className="min-w-0 flex-1">
<p className="flex items-baseline justify-between gap-3">
<span className="truncate text-[14.5px] font-medium text-foreground">{r.customer}</span>
<span className="shrink-0 text-[15px] font-semibold tabular-nums text-foreground">{f.money(r.amount)}</span>
</p>
<p className="mt-1 flex items-center justify-between gap-3 text-[12.5px]">
<span className="tabular-nums text-muted-foreground">
{r.number} · <span className={r.status === 'overdue' ? 'text-destructive' : ''}>{when(r)}</span>
</span>
<StatusBadge status={r.status} t={t} />
</p>
</div>
</div>
{action(r, true) && <div className="ps-[30px]">{action(r, true)}</div>}
</li>
))}
</ul>
</>
) : (
<p className="px-6 py-14 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>
)}
</section>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* pay */
/* ------------------------------------------------------------------ */
const luhn = (s: string) => {
const d = s.replace(/\D/g, '');
if (d.length < 13 || d.length > 19) return false;
let sum = 0;
for (let i = 0; i < d.length; i++) {
let n = Number(d[d.length - 1 - i]);
if (i % 2) n = n * 2 > 9 ? n * 2 - 9 : n * 2;
sum += n;
}
return sum % 10 === 0;
};
const ibanOk = (s: string) => {
const x = s.replace(/\s/g, '').toUpperCase();
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(x)) return false;
const r = (x.slice(4) + x.slice(0, 4)).replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
let m = 0;
for (const ch of r) m = (m * 10 + Number(ch)) % 97;
return m === 1;
};
const METHOD: Record<'card' | 'debit' | 'transfer', IconName> = { card: 'credit-card', debit: 'landmark', transfer: 'banknote' };
function Pay({ t, f, title, subtitle, invoice, methods = ['card', 'debit', 'transfer'], now, onAction }: P) {
const id = useId();
const [method, setMethod] = useState(methods[0] ?? 'card');
const [card, setCard] = useState({ name: '', number: '', exp: '', cvc: '' });
const [debit, setDebit] = useState({ name: '', iban: '', ok: false });
const [tried, setTried] = useState(false);
const [items, setItems] = useState(false);
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const ref = useGlide<HTMLDivElement>(method);
if (!invoice) return null;
const total = totals(invoice.items, invoice.discount).total;
const [mm, yy] = card.exp.split('/').map((x) => Number(x));
const nowY = Number(now.slice(2, 4));
const nowM = Number(now.slice(5, 7));
const errs: Record<string, string> =
method === 'card'
? {
...(card.name.trim().length < 3 ? { name: t.badName } : {}),
...(!luhn(card.number) ? { number: t.badCard } : {}),
...(!(mm >= 1 && mm <= 12 && (yy > nowY || (yy === nowY && mm >= nowM))) ? { exp: t.badExpiry } : {}),
...(!/^\d{3,4}$/.test(card.cvc) ? { cvc: t.badCvc } : {}),
}
: method === 'debit'
? { ...(debit.name.trim().length < 3 ? { dname: t.badName } : {}), ...(!ibanOk(debit.iban) ? { iban: t.badIban } : {}), ...(!debit.ok ? { ok: t.needMandate } : {}) }
: {};
const e = (k: string) => (tried ? errs[k] : undefined);
const submit = async () => {
setTried(true);
const keys = Object.keys(errs);
if (keys.length) {
requestAnimationFrame(() => document.getElementById(`${id}-${keys[0]}`)?.focus());
setMsg(keys.length === 1 ? t.fixOne : fill(t.fix, { n: keys.length }));
return;
}
setState('busy');
await run(onAction, 'pay', { number: invoice.number, method, amount: total }, 1300);
setState('done');
setMsg(method === 'transfer' ? t.thanksTransfer : t.thanks);
requestAnimationFrame(() => document.getElementById(`${id}-done`)?.focus());
};
const copy = async (what: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
} catch {
/* no clipboard, the text stays selectable */
}
setMsg(fill(t.copied, { what }));
};
const input = (k: string, label: string, value: string, set: (v: string) => void, extra: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
<label className={labelCls} htmlFor={`${id}-${k}`}>
{label}
<input id={`${id}-${k}`} value={value} aria-invalid={!!e(k)} aria-describedby={e(k) ? `${id}-${k}-e` : undefined} onChange={(ev) => set(ev.target.value)} className={cn(field, 'tabular-nums')} {...extra} />
{e(k) && (
<span id={`${id}-${k}-e`} className="text-[12.5px] font-normal text-destructive">
{e(k)}
</span>
)}
</label>
);
const receiptNo = `Q-${invoice.number.replace(/\D/g, '').slice(-6)}`;
return (
<Main className="max-w-[68rem]">
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:items-start">
{/* The invoice in short */}
<section aria-labelledby={`${id}-sum`} className={cn(cardCls, 'inv-up overflow-hidden lg:sticky lg:top-6')}>
<div className="p-5 sm:p-6">
<p className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
<span aria-hidden className="grid size-8 place-items-center rounded-lg bg-primary text-[13px] font-bold text-primary-foreground">{invoice.from.name.charAt(0)}</span>
<span id={`${id}-sum`}>{fill(t.payFrom, { number: invoice.number, name: invoice.from.name })}</span>
</p>
<p className="mt-5 text-[clamp(2rem,1.6rem+1.6vw,2.75rem)] font-semibold tracking-[-0.04em] tabular-nums text-foreground">{f.money(total)}</p>
<p className="mt-1 text-[13.5px] text-muted-foreground">
{t.due} {f.date(invoice.due, { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<button type="button" aria-expanded={items} aria-controls={`${id}-items`} onClick={() => setItems(!items)} className={cn('flex h-12 w-full cursor-pointer items-center justify-between border-t border-border px-5 text-[13.5px] font-medium text-foreground transition-colors hover:bg-surface sm:px-6', 'outline-none focus-visible:bg-[color-mix(in_oklab,var(--primary)_8%,transparent)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]')}>
{items ? t.hideItems : t.showItems}
<Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', items && 'rotate-180')} />
</button>
<div id={`${id}-items`} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', items ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!items}>
<div className="min-h-0 overflow-hidden">
<div className="border-t border-border bg-surface px-5 py-4 sm:px-6">
<ul className="grid gap-2.5">
{invoice.items.map((x) => (
<li key={x.id} className="flex justify-between gap-4 text-[13px]">
<span className="min-w-0 text-foreground">
{x.description}
<span className="block text-[12px] tabular-nums text-muted-foreground">
{f.num(x.qty)} {x.unit} × {f.money(x.price)}
</span>
</span>
<span className="shrink-0 tabular-nums text-foreground">{f.money(cents(x.qty * x.price))}</span>
</li>
))}
</ul>
<Totals items={invoice.items} discount={invoice.discount} t={t} f={f} className="mt-4 border-t border-border pt-3" />
</div>
</div>
</div>
</section>
{/* Payment */}
<section aria-labelledby={`${id}-pay`} className={cn(cardCls, 'inv-up p-5 [--inv-d:120ms] sm:p-6')}>
{state === 'done' ? (
<div id={`${id}-done`} tabIndex={-1} className="inv-in grid justify-items-center gap-3 py-8 text-center outline-none">
<svg viewBox="0 0 48 48" className="size-16" aria-hidden>
<circle cx="24" cy="24" r="22" fill="color-mix(in oklab, var(--success) 16%, transparent)" />
<path d="M15 24.5l6 6 12-13" fill="none" stroke="var(--success)" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round" className="inv-draw" />
</svg>
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-foreground">{method === 'transfer' ? t.thanksTransfer : t.thanks}</h2>
<p className="max-w-sm text-pretty text-[13.5px] text-muted-foreground">{fill(t.receipt, { no: receiptNo, email: invoice.to.email ?? invoice.to.name })}</p>
<Button variant="outline" size="sm" iconLeft="download" className="mt-2" onClick={async () => {
await run(onAction, 'receipt', { no: receiptNo }, 600);
setMsg(t.downloaded);
}}>
{t.getReceipt}
</Button>
</div>
) : (
<form
noValidate
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="grid gap-5"
>
<h2 id={`${id}-pay`} className="text-[15px] font-semibold text-foreground">
{t.method}
</h2>
<div
ref={ref}
role="radiogroup"
aria-labelledby={`${id}-pay`}
className="relative grid grid-cols-3 gap-2"
onKeyDown={(ev) => {
const dir = ev.key === 'ArrowRight' || ev.key === 'ArrowDown' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
ev.preventDefault();
const n = methods[(methods.indexOf(method) + dir + methods.length) % methods.length];
setMethod(n);
setTried(false);
ref.current?.querySelector<HTMLButtonElement>(`[data-m=${n}]`)?.focus();
}}
>
{methods.map((m) => {
const on = method === m;
return (
<button key={m} data-m={m} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => (setMethod(m), setTried(false))} className={cn('group relative grid min-h-[4.5rem] cursor-pointer content-center justify-items-center gap-1.5 rounded-lg border px-2 py-3 text-[12.5px] font-medium transition-[border-color,color,translate] duration-300 hover:-translate-y-0.5 sm:justify-items-start sm:px-3.5', focusRing, on ? 'border-transparent text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
{on && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] ring-2 ring-primary" />}
<Icon name={METHOD[m]} size={20} className={cn('relative', on && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')} />
<span className="relative text-center sm:text-left">{t[m]}</span>
</button>
);
})}
</div>
<div key={method} className="inv-in grid gap-4">
{method === 'card' && (
<>
{input('name', t.cardName, card.name, (v) => setCard({ ...card, name: v }), { autoComplete: 'cc-name' })}
{input('number', t.cardNumber, card.number, (v) => setCard({ ...card, number: v.replace(/\D/g, '').slice(0, 19).replace(/(\d{4})(?=\d)/g, '$1 ') }), { inputMode: 'numeric', autoComplete: 'cc-number', placeholder: '1234 5678 9012 3456' })}
<div className="grid grid-cols-2 gap-3">
{input('exp', t.expiry, card.exp, (v) => {
const d = v.replace(/\D/g, '').slice(0, 4);
setCard({ ...card, exp: d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d });
}, { inputMode: 'numeric', autoComplete: 'cc-exp', placeholder: t.expiryHint })}
{input('cvc', t.cvc, card.cvc, (v) => setCard({ ...card, cvc: v.replace(/\D/g, '').slice(0, 4) }), { inputMode: 'numeric', autoComplete: 'cc-csc', placeholder: '123' })}
</div>
</>
)}
{method === 'debit' && (
<>
{input('dname', t.holder, debit.name, (v) => setDebit({ ...debit, name: v }), { autoComplete: 'name' })}
{input('iban', t.iban, debit.iban, (v) => setDebit({ ...debit, iban: v.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 34).replace(/(.{4})(?=.)/g, '$1 ') }), { autoComplete: 'off', placeholder: 'DE89 3704 0044 0532 0130 00', spellCheck: false })}
<label className="flex cursor-pointer items-start gap-3 text-[13px] leading-snug text-muted-foreground" htmlFor={`${id}-ok`}>
<input id={`${id}-ok`} type="checkbox" checked={debit.ok} aria-invalid={!!e('ok')} onChange={(ev) => setDebit({ ...debit, ok: ev.target.checked })} className="mt-0.5 size-[18px] shrink-0 cursor-pointer accent-[var(--primary)]" />
<span>
{fill(t.mandate, { name: invoice.from.name })}
{e('ok') && <span className="mt-1 block text-destructive">{e('ok')}</span>}
</span>
</label>
</>
)}
{method === 'transfer' && invoice.bank && (
<div className="grid gap-3">
<p className="text-pretty text-[13.5px] text-muted-foreground">{t.transferText}</p>
<dl className="grid overflow-hidden rounded-lg border border-border">
{[
{ k: t.holder, v: invoice.bank.holder },
{ k: t.iban, v: invoice.bank.iban },
...(invoice.bank.bic ? [{ k: t.bic, v: invoice.bank.bic }] : []),
{ k: t.reference, v: invoice.number },
{ k: t.amount, v: f.money(total) },
].map((x) => (
<div key={x.k} className="flex items-center gap-3 border-b border-border bg-surface ps-4 last:border-b-0">
<dt className="w-24 shrink-0 py-2.5 text-[12.5px] text-muted-foreground">{x.k}</dt>
<dd className="min-w-0 flex-1 break-all py-2.5 text-[13.5px] tabular-nums text-foreground">{x.v}</dd>
<button type="button" aria-label={fill(t.copy, { what: x.k })} onClick={() => void copy(x.k, x.v)} className={cn(iconBtn, 'me-1')}>
<Icon name="copy" size={15} />
</button>
</div>
))}
</dl>
</div>
)}
</div>
<Button type="submit" size="lg" magnetic iconLeft={state === 'busy' ? 'loader' : method === 'transfer' ? 'check' : 'lock'} disabled={state === 'busy'} className="w-full">
{state === 'busy' ? t.paying : method === 'transfer' ? t.iTransferred : fill(t.payNow, { amount: f.money(total) })}
</Button>
{method === 'card' && (
<p className="flex items-center justify-center gap-2 text-center text-[12px] text-subtle-foreground">
<Icon name="shield" size={13} />
{t.secure}
</p>
)}
</form>
)}
</section>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* reminder */
/* ------------------------------------------------------------------ */
function Reminder({ t, f, title, subtitle, invoice, stages = [], sent = [], now, onAction }: P) {
const id = useId();
const late = invoice ? Math.max(0, dayOf(now) - dayOf(invoice.due)) : 0;
const [log, setLog] = useState(sent);
const nextIdx = () => {
const done = stages.findIndex((s) => !log.some((x) => x.stage === s.id));
return done < 0 ? stages.length - 1 : done;
};
const [sel, setSel] = useState(nextIdx);
const stage = stages[sel];
const [texts, setTexts] = useState(() => Object.fromEntries(stages.map((s) => [s.id, { subject: s.subject, body: s.body }])) as Record<string, { subject: string; body: string }>);
const [fee, setFee] = useState<Record<string, boolean>>(() => Object.fromEntries(stages.map((s) => [s.id, !!s.fee])));
const [state, setState] = useState<'idle' | 'busy'>('idle');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const ref = useGlide<HTMLOListElement>(sel);
if (!invoice || !stage) return null;
const open = totals(invoice.items, invoice.discount).total;
const feeOn = fee[stage.id] && stage.fee ? stage.fee : 0;
const vars = { name: invoice.to.contact ?? invoice.to.name, number: invoice.number, amount: f.money(open), due: f.date(invoice.due), days: late, total: f.money(open + feeOn), sender: invoice.from.name };
const cur = texts[stage.id] ?? { subject: stage.subject, body: stage.body };
const sentOf = (sid: string) => log.find((x) => x.stage === sid);
const go = (n: number) => {
const k = Math.max(0, Math.min(stages.length - 1, n));
setSel(k);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[k]?.focus();
};
const send = async () => {
setState('busy');
await run(onAction, 'remind', { number: invoice.number, stage: stage.id, subject: fill(cur.subject, vars), body: fill(cur.body, vars), fee: feeOn }, 900);
setLog((l) => [...l.filter((x) => x.stage !== stage.id), { stage: stage.id, time: now }]);
setState('idle');
setMsg(fill(t.reminderSent, { stage: stage.label, email: invoice.to.email ?? invoice.to.name }));
if (sel < stages.length - 1) setSel(sel + 1);
};
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid gap-5">
{/* The invoice it is about */}
<section className={cn(cardCls, 'inv-up grid grid-cols-2 gap-px overflow-hidden bg-border sm:grid-cols-4')}>
{[
{ k: t.invoice, v: invoice.number },
{ k: t.customer, v: invoice.to.name },
{ k: t.openAmount, v: f.money(open) },
{ k: t.due, v: f.date(invoice.due), late: true },
].map((x) => (
<div key={x.k} className="min-w-0 bg-card px-4 py-3.5 sm:px-5">
<p className="text-[12.5px] text-muted-foreground">{x.k}</p>
<p className="mt-0.5 truncate text-[15px] font-semibold tabular-nums text-foreground">{x.v}</p>
{x.late && late > 0 && <p className="mt-0.5 text-[12.5px] font-medium text-destructive">{fill(t.lateBy, { n: late })}</p>}
</div>
))}
</section>
{/* The stages on a line */}
<ol ref={ref} role="radiogroup" aria-label={t.stagesLabel} className="inv-up relative grid grid-cols-[minmax(0,1fr)] gap-2 [--inv-d:80ms] sm:grid-cols-[repeat(auto-fit,minmax(0,1fr))]">
{stages.map((s, i) => {
const on = i === sel;
const was = sentOf(s.id);
const due = !was && late >= s.day;
return (
<li key={s.id} className="min-w-0">
<button
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setSel(i)}
onKeyDown={(e) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (dir) (e.preventDefault(), go(i + dir));
}}
className={cn('group relative flex w-full cursor-pointer items-center gap-3 rounded-xl border p-3 text-left transition-[border-color,translate] duration-300 hover:-translate-y-0.5 sm:p-4', focusRing, on ? 'border-transparent' : 'border-border bg-card hover:border-border-strong')}
>
{on && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] ring-2 ring-primary" />}
<span className={cn('relative grid size-9 shrink-0 place-items-center rounded-full text-[13px] font-semibold tabular-nums', was ? 'bg-success text-white' : due ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}>{was ? <Icon name="check" size={16} strokeWidth={2.4} /> : i + 1}</span>
<span className="relative min-w-0">
<span className="block truncate text-[14px] font-medium text-foreground">{s.label}</span>
<span className="block truncate text-[12.5px] text-muted-foreground">
{fill(t.dayN, { n: s.day })} · {was ? fill(t.sentOn, { date: f.date(was.time.slice(0, 10), { day: 'numeric', month: 'short' }) }) : due ? t.recommended : t.later}
</span>
</span>
</button>
</li>
);
})}
</ol>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-2 lg:items-start">
<section aria-labelledby={`${id}-ed`} className={cn(cardCls, 'inv-up p-4 [--inv-d:140ms] sm:p-5')}>
<h2 id={`${id}-ed`} className="sr-only">
{stage.label}
</h2>
<div key={stage.id} className="inv-in grid gap-4">
<label className={labelCls}>
{t.to}
<input readOnly value={invoice.to.email ?? invoice.to.name} className={cn(field, 'bg-surface text-muted-foreground')} />
</label>
<label className={labelCls}>
{t.subject}
<input value={cur.subject} onChange={(e) => setTexts({ ...texts, [stage.id]: { ...cur, subject: e.target.value } })} className={field} />
</label>
<label className={labelCls}>
{t.message}
<textarea rows={8} value={cur.body} onChange={(e) => setTexts({ ...texts, [stage.id]: { ...cur, body: e.target.value } })} className={cn(field, 'h-auto resize-y py-2.5 leading-relaxed sm:h-auto')} />
</label>
<p className="text-[12px] text-subtle-foreground">{'{name} {number} {amount} {due} {days} {total} {sender}'}</p>
{stage.fee ? <Switch checked={!!fee[stage.id]} onCheckedChange={(v) => setFee({ ...fee, [stage.id]: v })} label={fill(t.addFee, { fee: f.money(stage.fee) })} /> : null}
</div>
</section>
<div className="grid gap-5">
<section aria-labelledby={`${id}-pv`} className={cn(cardCls, 'inv-up overflow-hidden [--inv-d:200ms]')}>
<h2 id={`${id}-pv`} className="border-b border-border bg-surface px-4 py-2.5 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground sm:px-5">
{t.emailPreview}
</h2>
<div key={stage.id + feeOn} className="inv-in p-4 sm:p-5">
<p className="text-[15px] font-semibold text-foreground">{fill(cur.subject, vars)}</p>
<p className="mt-3 whitespace-pre-line text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{fill(cur.body, vars)}</p>
<dl className="mt-4 grid gap-1.5 rounded-lg bg-surface p-3.5 text-[13px]">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{t.openAmount}</dt>
<dd className="tabular-nums text-foreground">{f.money(open)}</dd>
</div>
{feeOn > 0 && (
<div className="inv-in flex justify-between gap-4">
<dt className="text-muted-foreground">{t.fee}</dt>
<dd className="tabular-nums text-foreground">{f.money(feeOn)}</dd>
</div>
)}
<div className="flex justify-between gap-4 border-t border-border pt-1.5 font-semibold">
<dt className="text-foreground">{t.newTotal}</dt>
<dd className="tabular-nums text-foreground">{f.money(open + feeOn)}</dd>
</div>
</dl>
<span aria-hidden className="mt-4 inline-flex h-10 items-center rounded-md bg-primary px-4 text-[13px] font-medium text-primary-foreground">
{t.payButton}
</span>
</div>
<div className="border-t border-border p-4 sm:p-5">
<Button iconLeft={state === 'busy' ? 'loader' : 'send'} disabled={state === 'busy' || !cur.subject.trim()} onClick={() => void send()} magnetic className="w-full">
{state === 'busy' ? t.sending : fill(t.sendReminder, { stage: stage.label })}
</Button>
</div>
</section>
<section aria-labelledby={`${id}-h`} className={cn(cardCls, 'inv-up p-4 [--inv-d:240ms] sm:p-5')}>
<h2 id={`${id}-h`} className="text-[13px] font-semibold text-foreground">
{t.history}
</h2>
{log.length ? (
<ol className="relative mt-3 grid gap-3">
<span aria-hidden className="absolute bottom-2 start-[7px] top-2 w-px bg-border" />
{[...log].sort((a, b) => toMs(b.time) - toMs(a.time)).map((x) => (
<li key={x.stage + x.time} className="inv-in relative flex items-center gap-3 text-[13px]">
<span className="relative z-[1] size-[15px] shrink-0 rounded-full border-[3px] border-card bg-success" />
<span className="min-w-0 flex-1 truncate text-foreground">{stages.find((s) => s.id === x.stage)?.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{f.date(x.time.slice(0, 10))}</span>
</li>
))}
</ol>
) : (
<p className="mt-2 text-[13px] text-muted-foreground">{t.nothingSent}</p>
)}
</section>
</div>
</div>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* Invoice */
/* ------------------------------------------------------------------ */
/**
* Invoices in an app: a printable sheet, an editor with live sums, a list
* with filters and bulk actions, a page for the customer to pay, and payment
* reminders in stages. Amounts follow the language and the currency.
* <Invoice variant="document" invoice={{ number, issued, due, from, to, items }} currency="EUR" />
*/
export function Invoice({ variant = 'document', sidebar, labels, className, now = '2026-10-23T10:30', currency = 'EUR', ...props }: InvoiceProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const f = useMemo(() => makeFmt(locale, currency), [locale, currency]);
const p: P = { ...props, t, now, locale, f, currency };
const body = (() => {
switch (variant) {
case 'editor':
return <Editor {...p} />;
case 'list':
return <List {...p} />;
case 'pay':
return <Pay {...p} />;
case 'reminder':
return <Reminder {...p} />;
default:
return <DocumentView {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install Invoice?
With aiw add @ai-website/invoice. The command puts components/ui/invoice.tsx into your project, the code is yours from then on. It brings along button, icon, motion, switch and sidebar. It needs no further npm packages.
Which variants does Invoice have?
5 variants, Invoice document, Invoice editor, Invoice list, Pay an invoice and Payment reminders. You pick one with the variant prop, for example variant="editor".
Can I translate the texts of Invoice?
Yes. Invoice carries 143 texts of its own in English and German, such as “Invoice”, “Invoice number” and “Invoice date”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Invoice?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Invoice with aiw_components and install it with aiw_add @ai-website/invoice. Name the variant in your prompt, for example variant="document", and the assistant writes your content straight into the props.