Rechnung UI für Apps
Rechnungen in fünf Screens. Ein druckfertiges Blatt, ein Editor mit Live-Summen, eine Liste mit Filtern und Sammelaktionen, Bezahlen und Mahnstufen.
Von der Position bis zum Zahlungseingang
Rechnungen sind Dokumente, die Menschen drucken, weiterleiten und Zeile für Zeile prüfen, jede Summe muss also stimmen und jedes Datum richtig lesbar sein. Diese fünf Screens decken Schreiben, Senden, Verfolgen, Bezahlen und Erinnern ab, mit Beträgen und Daten im Format der Seitensprache.
Das „Rechnungsdokument“ zeigt Absender, Empfänger, Positionen, Netto, Steuer je Satz und Gesamt, Zahlungsdaten und einen Bezahlt-Stempel, mit PDF und einem sauberen Druck nur des Blatts. Der „Rechnungseditor“ fügt Positionen hinzu, verschiebt und entfernt sie, mit Menge, Preis, Steuersatz und Rabatt, die Summen rechnen live mit, vor dem Senden läuft eine Prüfung. Die „Rechnungsliste“ zeigt Summen für offen, überfällig und bezahlt, Statusfilter mit Zahlen, Suche, sortierbare Spalten und eine Sammelaktion für bezahlt, am Telefon als Karten. „Rechnung bezahlen“ ist die Kundenansicht mit Betrag, Fälligkeit und Positionen, per Karte, Lastschrift oder Überweisung mit geprüften Feldern. Die „Zahlungserinnerung“ setzt Mahnstufen nach Tagen im Verzug, einen Text mit Platzhaltern, eine Gebühr nach Wahl und eine Live-Vorschau der Mail.
Setz den offenen Betrag und die Fälligkeit oben auf jede Rechnung, danach suchen Menschen zuerst. Halte die erste Erinnerung freundlich, die meisten späten Zahlungen sind schlicht vergessen. Steuerregeln unterscheiden sich je Land, Sätze und Pflichtangaben kommen deshalb aus deinem eigenen System.
Variants
Details
Was Rechnung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- CRM und Rechnungen
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/invoice.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 143 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle InvoiceProps aus components/ui/invoice.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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.
Eingebaute Texte
Diese Texte schreibt Rechnung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 143 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| invoice | Englisch Invoice | Deutsch Rechnung |
| number | Englisch Invoice number | Deutsch Rechnungsnummer |
| issued | Englisch Invoice date | Deutsch Rechnungsdatum |
| due | Englisch Due date | Deutsch Fällig am |
| customerNo | Englisch Customer no. | Deutsch Kundennr. |
| reference | Englisch Reference | Deutsch Bezug |
| billTo | Englisch Bill to | Deutsch Rechnung an |
| from | Englisch From | Deutsch Von |
| pos | Englisch No. | Deutsch Pos. |
| description | Englisch Description | Deutsch Beschreibung |
| qty | Englisch Qty | Deutsch Menge |
| unitPrice | Englisch Unit price | Deutsch Einzelpreis |
| tax | Englisch Tax | Deutsch USt. |
| amount | Englisch Amount | Deutsch Betrag |
| net | Englisch Net | Deutsch Netto |
| discount | Englisch Discount {p} | Deutsch Rabatt {p} |
| taxOn | Englisch Tax {p} on {base} | Deutsch USt. {p} auf {base} |
| total | Englisch Total | Deutsch Gesamt |
| payTo | Englisch Payment | Deutsch Zahlung |
| payText | Englisch Please pay {amount} by {date}, quoting {number}. | Deutsch Bitte überweise {amount} bis zum {date} mit dem Verwendungszweck {number}. |
| holder | Englisch Account holder | Deutsch Kontoinhaber |
| iban | Englisch IBAN | Deutsch IBAN |
| bic | Englisch BIC | Deutsch BIC |
| bank | Englisch Bank | Deutsch Bank |
| vat | Englisch VAT ID | Deutsch USt-IdNr. |
| draft | Englisch Draft | Deutsch Entwurf |
| open | Englisch Open | Deutsch Offen |
| overdue | Englisch Overdue | Deutsch Überfällig |
| paid | Englisch Paid | Deutsch Bezahlt |
| Englisch Print | Deutsch Drucken | |
| download | Englisch Download PDF | Deutsch PDF herunterladen |
| downloading | Englisch Preparing PDF… | Deutsch PDF wird erstellt… |
| downloaded | Englisch The PDF is ready in your downloads. | Deutsch Das PDF liegt in deinen Downloads. |
| markPaid | Englisch Mark as paid | Deutsch Als bezahlt markieren |
| markedPaid | Englisch {number} is marked as paid. | Deutsch {number} ist als bezahlt markiert. |
| reopen | Englisch Mark as open | Deutsch Als offen markieren |
| paidOn | Englisch Paid on {date} | Deutsch Bezahlt am {date} |
| actions | Englisch Invoice actions | Deutsch Aktionen zur Rechnung |
| customer | Englisch Customer | Deutsch Kunde |
| terms | Englisch Payment terms | Deutsch Zahlungsziel |
| daysN | Englisch {n} days | Deutsch {n} Tage |
| lines | Englisch Line items | Deutsch Positionen |
| addLine | Englisch Add line | Deutsch Position hinzufügen |
| moveUp | Englisch Move line {n} up | Deutsch Position {n} nach oben |
| moveDown | Englisch Move line {n} down | Deutsch Position {n} nach unten |
| removeLine | Englisch Remove line {n} | Deutsch Position {n} entfernen |
| lineTotal | Englisch Line total | Deutsch Summe der Position |
| reorder | Englisch Alt and the arrow keys move a line | Deutsch Alt und die Pfeiltasten verschieben eine Position |
| discountPct | Englisch Discount in % | Deutsch Rabatt in % |
| note | Englisch Note on the invoice | Deutsch Hinweis auf der Rechnung |
| saveDraft | Englisch Save draft | Deutsch Entwurf speichern |
| saving | Englisch Saving… | Deutsch Wird gespeichert… |
| saved | Englisch Draft saved. | Deutsch Entwurf gespeichert. |
| send | Englisch Send invoice | Deutsch Rechnung senden |
| sending | Englisch Sending… | Deutsch Wird gesendet… |
| sent | Englisch Invoice {number} sent to {email}. | Deutsch Rechnung {number} an {email} gesendet. |
| edit | Englisch Edit | Deutsch Bearbeiten |
| preview | Englisch Preview | Deutsch Vorschau |
| mode | Englisch Editor or preview | Deutsch Editor oder Vorschau |
| fix | Englisch Please check {n} fields before sending. | Deutsch Bitte prüfe {n} Felder vor dem Senden. |
| fixOne | Englisch Please check one field before sending. | Deutsch Bitte prüfe ein Feld vor dem Senden. |
| needDesc | Englisch Describe the service. | Deutsch Beschreibe die Leistung. |
| needQty | Englisch Enter a quantity above zero. | Deutsch Trag eine Menge über null ein. |
| needPrice | Englisch Enter a price of zero or more. | Deutsch Trag einen Preis ab null ein. |
| needLine | Englisch Add at least one line. | Deutsch Füge mindestens eine Position hinzu. |
| needNumber | Englisch Give the invoice a number. | Deutsch Gib der Rechnung eine Nummer. |
| summary | Englisch Summary | Deutsch Summen |
| invoices | Englisch Invoices | Deutsch Rechnungen |
| all | Englisch All | Deutsch Alle |
| search | Englisch Search number or customer | Deutsch Nummer oder Kunde suchen |
| noMatch | Englisch No invoice matches. Try another filter. | Deutsch Keine Rechnung passt. Versuch einen anderen Filter. |
| openSum | Englisch Open | Deutsch Offen |
| overdueSum | Englisch Overdue | Deutsch Überfällig |
| paidSum | Englisch Paid this month | Deutsch Bezahlt diesen Monat |
| countN | Englisch {n} invoices | Deutsch {n} Rechnungen |
| sortBy | Englisch Sort by {col} | Deutsch Nach {col} sortieren |
| sort | Englisch Sort | Deutsch Sortieren |
| newest | Englisch Newest first | Deutsch Neueste zuerst |
| dueSoon | Englisch Due first | Deutsch Fällige zuerst |
| highest | Englisch Highest amount | Deutsch Höchster Betrag |
| select | Englisch Select {number} | Deutsch {number} auswählen |
| selectAll | Englisch Select all shown | Deutsch Alle gezeigten auswählen |
| selected | Englisch {n} selected | Deutsch {n} ausgewählt |
| clear | Englisch Clear selection | Deutsch Auswahl aufheben |
| bulkPaid | Englisch Mark as paid | Deutsch Als bezahlt markieren |
| bulkDone | Englisch {n} invoices marked as paid. | Deutsch {n} Rechnungen als bezahlt markiert. |
| remind | Englisch Send reminder | Deutsch Erinnerung senden |
| reminded | Englisch Reminder for {number} sent. | Deutsch Erinnerung zu {number} gesendet. |
| daysLate | Englisch {n} days late | Deutsch seit {n} Tagen fällig |
| dueIn | Englisch due in {n} days | Deutsch fällig in {n} Tagen |
| dueToday | Englisch due today | Deutsch heute fällig |
| status | Englisch Status | Deutsch Status |
| payInvoice | Englisch Pay invoice | Deutsch Rechnung bezahlen |
| payFrom | Englisch Invoice {number} from {name} | Deutsch Rechnung {number} von {name} |
| showItems | Englisch Show line items | Deutsch Positionen zeigen |
| hideItems | Englisch Hide line items | Deutsch Positionen ausblenden |
| method | Englisch Payment method | Deutsch Zahlart |
| card | Englisch Card | Deutsch Karte |
| debit | Englisch Direct debit | Deutsch Lastschrift |
| transfer | Englisch Bank transfer | Deutsch Überweisung |
| cardName | Englisch Name on card | Deutsch Name auf der Karte |
| cardNumber | Englisch Card number | Deutsch Kartennummer |
| expiry | Englisch Expiry (MM/YY) | Deutsch Gültig bis (MM/JJ) |
| expiryHint | Englisch MM/YY | Deutsch MM/JJ |
| cvc | Englisch Security code | Deutsch Prüfnummer |
| badCard | Englisch Check the card number. | Deutsch Prüf die Kartennummer. |
| badExpiry | Englisch Enter a future date as MM/YY. | Deutsch Trag ein künftiges Datum als MM/JJ ein. |
| badCvc | Englisch Enter the 3 or 4 digits on the back. | Deutsch Trag die 3 oder 4 Ziffern von der Rückseite ein. |
| badName | Englisch Enter the name as on the card. | Deutsch Trag den Namen wie auf der Karte ein. |
| badIban | Englisch Check the IBAN. | Deutsch Prüf die IBAN. |
| mandate | Englisch I allow {name} to collect this amount from my account once. | Deutsch Ich erlaube {name}, diesen Betrag einmalig von meinem Konto einzuziehen. |
| needMandate | Englisch Please agree to the direct debit. | Deutsch Bitte stimme der Lastschrift zu. |
| payNow | Englisch Pay {amount} | Deutsch {amount} bezahlen |
| paying | Englisch Processing payment… | Deutsch Zahlung läuft… |
| transferText | Englisch Transfer the amount to this account. Use the invoice number as reference so we can match it. | Deutsch Überweise den Betrag auf dieses Konto. Gib die Rechnungsnummer als Verwendungszweck an, dann ordnen wir sie sofort zu. |
| copy | Englisch Copy {what} | Deutsch {what} kopieren |
| copied | Englisch {what} copied. | Deutsch {what} kopiert. |
| iTransferred | Englisch I have sent the transfer | Deutsch Ich habe überwiesen |
| thanks | Englisch Thank you, payment received | Deutsch Danke, die Zahlung ist da |
| thanksTransfer | Englisch Thank you, we will confirm once the money arrives | Deutsch Danke, wir bestätigen, sobald das Geld eingeht |
| receipt | Englisch Receipt {no} goes to {email}. | Deutsch Beleg {no} geht an {email}. |
| getReceipt | Englisch Download receipt | Deutsch Beleg herunterladen |
| secure | Englisch Encrypted connection. Card data never touches our server. | Deutsch Verschlüsselte Verbindung. Kartendaten erreichen nie unseren Server. |
| reminder | Englisch Payment reminder | Deutsch Zahlungserinnerung |
| stagesLabel | Englisch Reminder stage | Deutsch Mahnstufe |
| dayN | Englisch Day {n} | Deutsch Tag {n} |
| sentOn | Englisch Sent {date} | Deutsch Gesendet {date} |
| recommended | Englisch Due now | Deutsch Jetzt dran |
| later | Englisch Later | Deutsch Später |
| to | Englisch To | Deutsch An |
| subject | Englisch Subject | Deutsch Betreff |
| message | Englisch Message | Deutsch Nachricht |
| addFee | Englisch Add fee of {fee} | Deutsch Gebühr von {fee} hinzufügen |
| openAmount | Englisch Open amount | Deutsch Offener Betrag |
| fee | Englisch Fee | Deutsch Gebühr |
| newTotal | Englisch New total | Deutsch Neuer Gesamtbetrag |
| sendReminder | Englisch Send {stage} | Deutsch {stage} senden |
| reminderSent | Englisch {stage} sent to {email}. | Deutsch {stage} an {email} gesendet. |
| history | Englisch Sent so far | Deutsch Bisher gesendet |
| nothingSent | Englisch No reminder has gone out yet. | Deutsch Bisher ging keine Erinnerung raus. |
| lateBy | Englisch {n} days overdue | Deutsch seit {n} Tagen überfällig |
| emailPreview | Englisch Email preview | Deutsch Vorschau der E-Mail |
| payButton | Englisch Pay now | Deutsch Jetzt bezahlen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Rechnung reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live und aria-sort, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, group, radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.890 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Rechnung?
Mit aiw add @ai-website/invoice. Der Befehl legt die Datei components/ui/invoice.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, switch und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Rechnung?
5 Varianten, Rechnungsdokument, Rechnungseditor, Rechnungsliste, Rechnung bezahlen und Zahlungserinnerung. Du wählst sie mit der Prop variant, zum Beispiel variant="editor".
Kann ich die Texte von Rechnung übersetzen?
Ja. Rechnung bringt 143 eigene Texte auf Deutsch und Englisch mit, etwa „Rechnung“, „Rechnungsnummer“ und „Rechnungsdatum“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Rechnung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Rechnung mit aiw_components und installieren sie mit aiw_add @ai-website/invoice. Nenn im Prompt die Variante, etwa variant="document", dann schreibt der Assistent deine Inhalte direkt in die Props.