Skip to content
UI Kit
Accent color

Invoice UI for apps

Invoices in five screens. A printable sheet, an editor with live sums, a list with filters and bulk actions, a page to pay and reminders in stages.

5 variantsOne of 2 components in CRM and invoicing

From line items to money received

Invoices are documents people print, forward and check line by line, so every sum has to add up and every date has to read right. These five screens cover writing, sending, tracking, paying and reminding, with amounts and dates in the format of the page language.

Invoice document shows sender, recipient, line items, net, tax per rate and total, payment details and a paid stamp, with PDF download and a clean print of the sheet only. Invoice editor adds, moves and removes lines with quantity, price, tax rate and discount, sums update live, and checks run before sending. Invoice list shows sums for open, overdue and paid, status filters with counts, search, sortable columns and bulk mark as paid, cards on phones. Pay an invoice is the customer view with amount, due date and line items and card, direct debit or bank transfer with checked fields. Payment reminders sets stages by days overdue with a text with placeholders, an optional fee and a live email preview.

Put the amount due and the due date at the top of every invoice, that is what people look for first. Keep reminder texts friendly in the first stage, most late payments are simply forgotten. Tax rules differ by country, so pass rates and legal lines in from your own system.

Variants

Invoice documentNew in 1.2

Sender, recipient, line items, net, tax per rate and total, payment details, a paid stamp, PDF download and a clean print of the sheet only.

Invoice editorNew in 1.2

Add, move and remove lines, quantity, price, tax rate and discount, sums live in the format of the language, checks before sending and a preview.

Invoice listNew in 1.2

Sums for open, overdue and paid, status filters with counts, search, sortable columns, bulk mark as paid, reminders and cards on phones.

Pay an invoiceNew in 1.2

The customer view with amount, due date and line items, card, direct debit or bank transfer with checked fields and a calm thank you.

Payment remindersNew in 1.2

Reminder stages by days overdue, the text with placeholders, an optional fee, a live email preview, send and a history of what went out.

Details

What Invoice brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/invoice.tsx
Brings along
utilskitbuttoniconmotionswitchsidebar
npm packages
None
Languages
English and German, 143 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The InvoiceProps interface from components/ui/invoice.tsx, with the comments from the code.

  • 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.

Built-in texts

Invoice writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 143 texts
invoiceEnglish InvoiceGerman Rechnung
numberEnglish Invoice numberGerman Rechnungsnummer
issuedEnglish Invoice dateGerman Rechnungsdatum
dueEnglish Due dateGerman Fällig am
customerNoEnglish Customer no.German Kundennr.
referenceEnglish ReferenceGerman Bezug
billToEnglish Bill toGerman Rechnung an
fromEnglish FromGerman Von
posEnglish No.German Pos.
descriptionEnglish DescriptionGerman Beschreibung
qtyEnglish QtyGerman Menge
unitPriceEnglish Unit priceGerman Einzelpreis
taxEnglish TaxGerman USt.
amountEnglish AmountGerman Betrag
netEnglish NetGerman Netto
discountEnglish Discount {p}German Rabatt {p}
taxOnEnglish Tax {p} on {base}German USt. {p} auf {base}
totalEnglish TotalGerman Gesamt
payToEnglish PaymentGerman Zahlung
payTextEnglish Please pay {amount} by {date}, quoting {number}.German Bitte überweise {amount} bis zum {date} mit dem Verwendungszweck {number}.
holderEnglish Account holderGerman Kontoinhaber
ibanEnglish IBANGerman IBAN
bicEnglish BICGerman BIC
bankEnglish BankGerman Bank
vatEnglish VAT IDGerman USt-IdNr.
draftEnglish DraftGerman Entwurf
openEnglish OpenGerman Offen
overdueEnglish OverdueGerman Überfällig
paidEnglish PaidGerman Bezahlt
printEnglish PrintGerman Drucken
downloadEnglish Download PDFGerman PDF herunterladen
downloadingEnglish Preparing PDF…German PDF wird erstellt…
downloadedEnglish The PDF is ready in your downloads.German Das PDF liegt in deinen Downloads.
markPaidEnglish Mark as paidGerman Als bezahlt markieren
markedPaidEnglish {number} is marked as paid.German {number} ist als bezahlt markiert.
reopenEnglish Mark as openGerman Als offen markieren
paidOnEnglish Paid on {date}German Bezahlt am {date}
actionsEnglish Invoice actionsGerman Aktionen zur Rechnung
customerEnglish CustomerGerman Kunde
termsEnglish Payment termsGerman Zahlungsziel
daysNEnglish {n} daysGerman {n} Tage
linesEnglish Line itemsGerman Positionen
addLineEnglish Add lineGerman Position hinzufügen
moveUpEnglish Move line {n} upGerman Position {n} nach oben
moveDownEnglish Move line {n} downGerman Position {n} nach unten
removeLineEnglish Remove line {n}German Position {n} entfernen
lineTotalEnglish Line totalGerman Summe der Position
reorderEnglish Alt and the arrow keys move a lineGerman Alt und die Pfeiltasten verschieben eine Position
discountPctEnglish Discount in %German Rabatt in %
noteEnglish Note on the invoiceGerman Hinweis auf der Rechnung
saveDraftEnglish Save draftGerman Entwurf speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Draft saved.German Entwurf gespeichert.
sendEnglish Send invoiceGerman Rechnung senden
sendingEnglish Sending…German Wird gesendet…
sentEnglish Invoice {number} sent to {email}.German Rechnung {number} an {email} gesendet.
editEnglish EditGerman Bearbeiten
previewEnglish PreviewGerman Vorschau
modeEnglish Editor or previewGerman Editor oder Vorschau
fixEnglish Please check {n} fields before sending.German Bitte prüfe {n} Felder vor dem Senden.
fixOneEnglish Please check one field before sending.German Bitte prüfe ein Feld vor dem Senden.
needDescEnglish Describe the service.German Beschreibe die Leistung.
needQtyEnglish Enter a quantity above zero.German Trag eine Menge über null ein.
needPriceEnglish Enter a price of zero or more.German Trag einen Preis ab null ein.
needLineEnglish Add at least one line.German Füge mindestens eine Position hinzu.
needNumberEnglish Give the invoice a number.German Gib der Rechnung eine Nummer.
summaryEnglish SummaryGerman Summen
invoicesEnglish InvoicesGerman Rechnungen
allEnglish AllGerman Alle
searchEnglish Search number or customerGerman Nummer oder Kunde suchen
noMatchEnglish No invoice matches. Try another filter.German Keine Rechnung passt. Versuch einen anderen Filter.
openSumEnglish OpenGerman Offen
overdueSumEnglish OverdueGerman Überfällig
paidSumEnglish Paid this monthGerman Bezahlt diesen Monat
countNEnglish {n} invoicesGerman {n} Rechnungen
sortByEnglish Sort by {col}German Nach {col} sortieren
sortEnglish SortGerman Sortieren
newestEnglish Newest firstGerman Neueste zuerst
dueSoonEnglish Due firstGerman Fällige zuerst
highestEnglish Highest amountGerman Höchster Betrag
selectEnglish Select {number}German {number} auswählen
selectAllEnglish Select all shownGerman Alle gezeigten auswählen
selectedEnglish {n} selectedGerman {n} ausgewählt
clearEnglish Clear selectionGerman Auswahl aufheben
bulkPaidEnglish Mark as paidGerman Als bezahlt markieren
bulkDoneEnglish {n} invoices marked as paid.German {n} Rechnungen als bezahlt markiert.
remindEnglish Send reminderGerman Erinnerung senden
remindedEnglish Reminder for {number} sent.German Erinnerung zu {number} gesendet.
daysLateEnglish {n} days lateGerman seit {n} Tagen fällig
dueInEnglish due in {n} daysGerman fällig in {n} Tagen
dueTodayEnglish due todayGerman heute fällig
statusEnglish StatusGerman Status
payInvoiceEnglish Pay invoiceGerman Rechnung bezahlen
payFromEnglish Invoice {number} from {name}German Rechnung {number} von {name}
showItemsEnglish Show line itemsGerman Positionen zeigen
hideItemsEnglish Hide line itemsGerman Positionen ausblenden
methodEnglish Payment methodGerman Zahlart
cardEnglish CardGerman Karte
debitEnglish Direct debitGerman Lastschrift
transferEnglish Bank transferGerman Überweisung
cardNameEnglish Name on cardGerman Name auf der Karte
cardNumberEnglish Card numberGerman Kartennummer
expiryEnglish Expiry (MM/YY)German Gültig bis (MM/JJ)
expiryHintEnglish MM/YYGerman MM/JJ
cvcEnglish Security codeGerman Prüfnummer
badCardEnglish Check the card number.German Prüf die Kartennummer.
badExpiryEnglish Enter a future date as MM/YY.German Trag ein künftiges Datum als MM/JJ ein.
badCvcEnglish Enter the 3 or 4 digits on the back.German Trag die 3 oder 4 Ziffern von der Rückseite ein.
badNameEnglish Enter the name as on the card.German Trag den Namen wie auf der Karte ein.
badIbanEnglish Check the IBAN.German Prüf die IBAN.
mandateEnglish I allow {name} to collect this amount from my account once.German Ich erlaube {name}, diesen Betrag einmalig von meinem Konto einzuziehen.
needMandateEnglish Please agree to the direct debit.German Bitte stimme der Lastschrift zu.
payNowEnglish Pay {amount}German {amount} bezahlen
payingEnglish Processing payment…German Zahlung läuft…
transferTextEnglish Transfer the amount to this account. Use the invoice number as reference so we can match it.German Überweise den Betrag auf dieses Konto. Gib die Rechnungsnummer als Verwendungszweck an, dann ordnen wir sie sofort zu.
copyEnglish Copy {what}German {what} kopieren
copiedEnglish {what} copied.German {what} kopiert.
iTransferredEnglish I have sent the transferGerman Ich habe überwiesen
thanksEnglish Thank you, payment receivedGerman Danke, die Zahlung ist da
thanksTransferEnglish Thank you, we will confirm once the money arrivesGerman Danke, wir bestätigen, sobald das Geld eingeht
receiptEnglish Receipt {no} goes to {email}.German Beleg {no} geht an {email}.
getReceiptEnglish Download receiptGerman Beleg herunterladen
secureEnglish Encrypted connection. Card data never touches our server.German Verschlüsselte Verbindung. Kartendaten erreichen nie unseren Server.
reminderEnglish Payment reminderGerman Zahlungserinnerung
stagesLabelEnglish Reminder stageGerman Mahnstufe
dayNEnglish Day {n}German Tag {n}
sentOnEnglish Sent {date}German Gesendet {date}
recommendedEnglish Due nowGerman Jetzt dran
laterEnglish LaterGerman Später
toEnglish ToGerman An
subjectEnglish SubjectGerman Betreff
messageEnglish MessageGerman Nachricht
addFeeEnglish Add fee of {fee}German Gebühr von {fee} hinzufügen
openAmountEnglish Open amountGerman Offener Betrag
feeEnglish FeeGerman Gebühr
newTotalEnglish New totalGerman Neuer Gesamtbetrag
sendReminderEnglish Send {stage}German {stage} senden
reminderSentEnglish {stage} sent to {email}.German {stage} an {email} gesendet.
historyEnglish Sent so farGerman Bisher gesendet
nothingSentEnglish No reminder has gone out yet.German Bisher ging keine Erinnerung raus.
lateByEnglish {n} days overdueGerman seit {n} Tagen überfällig
emailPreviewEnglish Email previewGerman Vorschau der E-Mail
payButtonEnglish Pay nowGerman Jetzt bezahlen

Accessibility

Read from the source of the component.

  • Invoice handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live and aria-sort, so screen readers announce purpose and state.
  • Uses the roles alert, group, radio, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,890 lines
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>
  );
}

Frequent questions

How do I install Invoice?

With aiw add @ai-website/invoice. The command puts components/ui/invoice.tsx into your project, the code is yours from then on. It brings along button, icon, motion, switch and sidebar. It needs no further npm packages.

Which variants does Invoice have?

5 variants, Invoice document, Invoice editor, Invoice list, Pay an invoice and Payment reminders. You pick one with the variant prop, for example variant="editor".

Can I translate the texts of Invoice?

Yes. Invoice carries 143 texts of its own in English and German, such as “Invoice”, “Invoice number” and “Invoice date”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Invoice?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Invoice with aiw_components and install it with aiw_add @ai-website/invoice. Name the variant in your prompt, for example variant="document", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard