Zum Inhalt springen
UI Kit
Accent

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.

5 VariantsEine von 2 Components in CRM und Rechnungen

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

RechnungsdokumentNeu in 1.2

Absender, Empfänger, Positionen, Netto, Steuer je Satz und Gesamt, Zahlungsdaten, ein Bezahlt-Stempel, PDF und ein sauberer Druck nur des Blatts.

RechnungseditorNeu in 1.2

Positionen hinzufügen, verschieben und entfernen, Menge, Preis, Steuersatz und Rabatt, Summen live je Sprache, Prüfung vor dem Senden und Vorschau.

RechnungslisteNeu in 1.2

Summen für offen, überfällig und bezahlt, Statusfilter mit Zahlen, Suche, sortierbare Spalten, Sammelaktion bezahlt, Erinnerungen und Karten am Telefon.

Rechnung bezahlenNeu in 1.2

Die Kundenansicht mit Betrag, Fälligkeit und Positionen, Karte, Lastschrift oder Überweisung mit geprüften Feldern und einem ruhigen Dank.

ZahlungserinnerungNeu in 1.2

Mahnstufen nach Tagen im Verzug, der Text mit Platzhaltern, eine Gebühr nach Wahl, eine Live-Vorschau der Mail, Senden und Verlauf des Gesendeten.

Details

Was Rechnung mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/invoice.tsx
Bringt mit
utilskitbuttoniconmotionswitchsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 143 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle InvoiceProps aus components/ui/invoice.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    InvoiceVariant

    documenteditorlistpayreminder

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • currencyoptional
    string

    ISO currency code, e.g. EUR or USD

  • invoiceoptional
    InvoiceData

    The invoice on screen (document, editor, pay, reminder)

  • invoicesoptional
    InvoiceRow[]

    list: all invoices

  • taxRatesoptional
    number[]

    editor: tax rates to pick from

  • customersoptional
    InvoiceParty[]

    editor: customers to pick from

  • methodsoptional
    ('card' | 'debit' | 'transfer')[]

    pay: the payment methods on offer

  • stagesoptional
    ReminderStage[]

    reminder: the steps in order

  • sentoptional
    ReminderSent[]

    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
invoiceEnglisch InvoiceDeutsch Rechnung
numberEnglisch Invoice numberDeutsch Rechnungsnummer
issuedEnglisch Invoice dateDeutsch Rechnungsdatum
dueEnglisch Due dateDeutsch Fällig am
customerNoEnglisch Customer no.Deutsch Kundennr.
referenceEnglisch ReferenceDeutsch Bezug
billToEnglisch Bill toDeutsch Rechnung an
fromEnglisch FromDeutsch Von
posEnglisch No.Deutsch Pos.
descriptionEnglisch DescriptionDeutsch Beschreibung
qtyEnglisch QtyDeutsch Menge
unitPriceEnglisch Unit priceDeutsch Einzelpreis
taxEnglisch TaxDeutsch USt.
amountEnglisch AmountDeutsch Betrag
netEnglisch NetDeutsch Netto
discountEnglisch Discount {p}Deutsch Rabatt {p}
taxOnEnglisch Tax {p} on {base}Deutsch USt. {p} auf {base}
totalEnglisch TotalDeutsch Gesamt
payToEnglisch PaymentDeutsch Zahlung
payTextEnglisch Please pay {amount} by {date}, quoting {number}.Deutsch Bitte überweise {amount} bis zum {date} mit dem Verwendungszweck {number}.
holderEnglisch Account holderDeutsch Kontoinhaber
ibanEnglisch IBANDeutsch IBAN
bicEnglisch BICDeutsch BIC
bankEnglisch BankDeutsch Bank
vatEnglisch VAT IDDeutsch USt-IdNr.
draftEnglisch DraftDeutsch Entwurf
openEnglisch OpenDeutsch Offen
overdueEnglisch OverdueDeutsch Überfällig
paidEnglisch PaidDeutsch Bezahlt
printEnglisch PrintDeutsch Drucken
downloadEnglisch Download PDFDeutsch PDF herunterladen
downloadingEnglisch Preparing PDF…Deutsch PDF wird erstellt…
downloadedEnglisch The PDF is ready in your downloads.Deutsch Das PDF liegt in deinen Downloads.
markPaidEnglisch Mark as paidDeutsch Als bezahlt markieren
markedPaidEnglisch {number} is marked as paid.Deutsch {number} ist als bezahlt markiert.
reopenEnglisch Mark as openDeutsch Als offen markieren
paidOnEnglisch Paid on {date}Deutsch Bezahlt am {date}
actionsEnglisch Invoice actionsDeutsch Aktionen zur Rechnung
customerEnglisch CustomerDeutsch Kunde
termsEnglisch Payment termsDeutsch Zahlungsziel
daysNEnglisch {n} daysDeutsch {n} Tage
linesEnglisch Line itemsDeutsch Positionen
addLineEnglisch Add lineDeutsch Position hinzufügen
moveUpEnglisch Move line {n} upDeutsch Position {n} nach oben
moveDownEnglisch Move line {n} downDeutsch Position {n} nach unten
removeLineEnglisch Remove line {n}Deutsch Position {n} entfernen
lineTotalEnglisch Line totalDeutsch Summe der Position
reorderEnglisch Alt and the arrow keys move a lineDeutsch Alt und die Pfeiltasten verschieben eine Position
discountPctEnglisch Discount in %Deutsch Rabatt in %
noteEnglisch Note on the invoiceDeutsch Hinweis auf der Rechnung
saveDraftEnglisch Save draftDeutsch Entwurf speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Draft saved.Deutsch Entwurf gespeichert.
sendEnglisch Send invoiceDeutsch Rechnung senden
sendingEnglisch Sending…Deutsch Wird gesendet…
sentEnglisch Invoice {number} sent to {email}.Deutsch Rechnung {number} an {email} gesendet.
editEnglisch EditDeutsch Bearbeiten
previewEnglisch PreviewDeutsch Vorschau
modeEnglisch Editor or previewDeutsch Editor oder Vorschau
fixEnglisch Please check {n} fields before sending.Deutsch Bitte prüfe {n} Felder vor dem Senden.
fixOneEnglisch Please check one field before sending.Deutsch Bitte prüfe ein Feld vor dem Senden.
needDescEnglisch Describe the service.Deutsch Beschreibe die Leistung.
needQtyEnglisch Enter a quantity above zero.Deutsch Trag eine Menge über null ein.
needPriceEnglisch Enter a price of zero or more.Deutsch Trag einen Preis ab null ein.
needLineEnglisch Add at least one line.Deutsch Füge mindestens eine Position hinzu.
needNumberEnglisch Give the invoice a number.Deutsch Gib der Rechnung eine Nummer.
summaryEnglisch SummaryDeutsch Summen
invoicesEnglisch InvoicesDeutsch Rechnungen
allEnglisch AllDeutsch Alle
searchEnglisch Search number or customerDeutsch Nummer oder Kunde suchen
noMatchEnglisch No invoice matches. Try another filter.Deutsch Keine Rechnung passt. Versuch einen anderen Filter.
openSumEnglisch OpenDeutsch Offen
overdueSumEnglisch OverdueDeutsch Überfällig
paidSumEnglisch Paid this monthDeutsch Bezahlt diesen Monat
countNEnglisch {n} invoicesDeutsch {n} Rechnungen
sortByEnglisch Sort by {col}Deutsch Nach {col} sortieren
sortEnglisch SortDeutsch Sortieren
newestEnglisch Newest firstDeutsch Neueste zuerst
dueSoonEnglisch Due firstDeutsch Fällige zuerst
highestEnglisch Highest amountDeutsch Höchster Betrag
selectEnglisch Select {number}Deutsch {number} auswählen
selectAllEnglisch Select all shownDeutsch Alle gezeigten auswählen
selectedEnglisch {n} selectedDeutsch {n} ausgewählt
clearEnglisch Clear selectionDeutsch Auswahl aufheben
bulkPaidEnglisch Mark as paidDeutsch Als bezahlt markieren
bulkDoneEnglisch {n} invoices marked as paid.Deutsch {n} Rechnungen als bezahlt markiert.
remindEnglisch Send reminderDeutsch Erinnerung senden
remindedEnglisch Reminder for {number} sent.Deutsch Erinnerung zu {number} gesendet.
daysLateEnglisch {n} days lateDeutsch seit {n} Tagen fällig
dueInEnglisch due in {n} daysDeutsch fällig in {n} Tagen
dueTodayEnglisch due todayDeutsch heute fällig
statusEnglisch StatusDeutsch Status
payInvoiceEnglisch Pay invoiceDeutsch Rechnung bezahlen
payFromEnglisch Invoice {number} from {name}Deutsch Rechnung {number} von {name}
showItemsEnglisch Show line itemsDeutsch Positionen zeigen
hideItemsEnglisch Hide line itemsDeutsch Positionen ausblenden
methodEnglisch Payment methodDeutsch Zahlart
cardEnglisch CardDeutsch Karte
debitEnglisch Direct debitDeutsch Lastschrift
transferEnglisch Bank transferDeutsch Überweisung
cardNameEnglisch Name on cardDeutsch Name auf der Karte
cardNumberEnglisch Card numberDeutsch Kartennummer
expiryEnglisch Expiry (MM/YY)Deutsch Gültig bis (MM/JJ)
expiryHintEnglisch MM/YYDeutsch MM/JJ
cvcEnglisch Security codeDeutsch Prüfnummer
badCardEnglisch Check the card number.Deutsch Prüf die Kartennummer.
badExpiryEnglisch Enter a future date as MM/YY.Deutsch Trag ein künftiges Datum als MM/JJ ein.
badCvcEnglisch Enter the 3 or 4 digits on the back.Deutsch Trag die 3 oder 4 Ziffern von der Rückseite ein.
badNameEnglisch Enter the name as on the card.Deutsch Trag den Namen wie auf der Karte ein.
badIbanEnglisch Check the IBAN.Deutsch Prüf die IBAN.
mandateEnglisch I allow {name} to collect this amount from my account once.Deutsch Ich erlaube {name}, diesen Betrag einmalig von meinem Konto einzuziehen.
needMandateEnglisch Please agree to the direct debit.Deutsch Bitte stimme der Lastschrift zu.
payNowEnglisch Pay {amount}Deutsch {amount} bezahlen
payingEnglisch Processing payment…Deutsch Zahlung läuft…
transferTextEnglisch 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.
copyEnglisch Copy {what}Deutsch {what} kopieren
copiedEnglisch {what} copied.Deutsch {what} kopiert.
iTransferredEnglisch I have sent the transferDeutsch Ich habe überwiesen
thanksEnglisch Thank you, payment receivedDeutsch Danke, die Zahlung ist da
thanksTransferEnglisch Thank you, we will confirm once the money arrivesDeutsch Danke, wir bestätigen, sobald das Geld eingeht
receiptEnglisch Receipt {no} goes to {email}.Deutsch Beleg {no} geht an {email}.
getReceiptEnglisch Download receiptDeutsch Beleg herunterladen
secureEnglisch Encrypted connection. Card data never touches our server.Deutsch Verschlüsselte Verbindung. Kartendaten erreichen nie unseren Server.
reminderEnglisch Payment reminderDeutsch Zahlungserinnerung
stagesLabelEnglisch Reminder stageDeutsch Mahnstufe
dayNEnglisch Day {n}Deutsch Tag {n}
sentOnEnglisch Sent {date}Deutsch Gesendet {date}
recommendedEnglisch Due nowDeutsch Jetzt dran
laterEnglisch LaterDeutsch Später
toEnglisch ToDeutsch An
subjectEnglisch SubjectDeutsch Betreff
messageEnglisch MessageDeutsch Nachricht
addFeeEnglisch Add fee of {fee}Deutsch Gebühr von {fee} hinzufügen
openAmountEnglisch Open amountDeutsch Offener Betrag
feeEnglisch FeeDeutsch Gebühr
newTotalEnglisch New totalDeutsch Neuer Gesamtbetrag
sendReminderEnglisch Send {stage}Deutsch {stage} senden
reminderSentEnglisch {stage} sent to {email}.Deutsch {stage} an {email} gesendet.
historyEnglisch Sent so farDeutsch Bisher gesendet
nothingSentEnglisch No reminder has gone out yet.Deutsch Bisher ging keine Erinnerung raus.
lateByEnglisch {n} days overdueDeutsch seit {n} Tagen überfällig
emailPreviewEnglisch Email previewDeutsch Vorschau der E-Mail
payButtonEnglisch Pay nowDeutsch 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
components/ui/invoice.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert