/** THE money surface for one record — charges, tax, payments, what is owed. */ import { useRef, useState } from "react"; import { Text } from "@lotics/ui/text"; import { Alert } from "@lotics/ui/alert"; import { Box } from "@lotics/ui/box"; import { Button } from "@lotics/ui/button"; import { ActionMenu } from "@lotics/ui/action_menu"; import { Link } from "@lotics/ui/link"; import { TextLink } from "@lotics/ui/text_link"; import { Callout, CalloutText } from "@lotics/ui/callout"; import { PageContent } from "@lotics/ui/page_content"; import { Section, SectionHeading, SectionHeadingTitle, Subsection } from "@lotics/ui/section_heading"; import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack"; import { Stack } from "@lotics/ui/stack"; import { DetailRow, DetailTable } from "@lotics/ui/detail_row"; import { TotalsLine } from "@lotics/ui/totals_line"; import { RegionState } from "@lotics/ui/region_state"; import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table"; import { FileBadge } from "@lotics/ui/file_badge"; import { FileGalleryDialog } from "@lotics/ui/file_gallery_dialog"; import { type DisplayFile } from "@lotics/ui/file_thumbnail"; import { InlineButton } from "@lotics/ui/inline_button"; import { DatePicker } from "@lotics/ui/date_picker"; import { InlineFiles } from "@lotics/ui/inline_files"; import { NumberInput } from "@lotics/ui/number_input"; import { Select } from "@lotics/ui/select"; import { TextInput } from "@lotics/ui/text_input"; import { ChargeLine, ChargeLines } from "@lotics/ui/charge_lines"; import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger"; import { MoneyBand } from "@lotics/ui/money_band"; import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog"; import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline"; import { formatMoney } from "@lotics/ui/format_money"; import { useLoticsLocale } from "@lotics/ui/locale"; import type { SelectOption } from "@lotics/ui/select"; // ───────────────────────────────────────────────────────────────────────────── // Template, Money — THE money surface: one record's money, in every shape it // takes. Four kinds of screen live here and they are not interchangeable: // // FEES — the DETAILED view, both directions (charge/cost), each fee with // its party, due date, supplier original and paid state. A // register whose every row expands in place to its full detail. // BILLING — the invoice DOCUMENTS: per-invoice charge bands, the closing // `Ledger` statement, the deposit and the receipt. // CHARGES — `ChargeLines`, the editable band you PRICE into: priced lines, // flat amounts, mixed kinds, locked, empty, and the narrow fork. // // The split that governs the page: `ChargeLines` is what you are PRICING and // `Ledger` is what you are READING — a band you type into versus a statement // you check. Both keep every figure on one right-aligned tabular column, // because a money column that lands on more edges than it has kinds cannot be // added up by eye. // // Fees and Billing are lifted from `tpl_record`, where they are two sections of // a much larger record surface; here they are the subject rather than a part of // one, and the charge bands below them show the shapes a record's money takes // that no single scenario exercises at once. // ───────────────────────────────────────────────────────────────────────────── /** ONE ₫ formatter for the page — a template that spells its home currency two * ways teaches two ways. `formatMoney` is the kit's, so a table cell and a * charge line cannot disagree about digit grouping. */ const money = (v: number | null) => (v == null ? "" : formatMoney(v)); /** The same notation as `Intl` options — what a numeric field PRINTS and what * its parser will take back, which is one statement and not two. */ const VND: Intl.NumberFormatOptions = { style: "currency", currency: "VND" }; /** A SECOND currency, for the one band that is denominated in it. A band formats * its own money (`ChargeLines.currency`), which is exactly what lets a * foreign-currency charge sit on the same page as the domestic ones. */ const eur = (n: number) => `€${n.toLocaleString("en-IE", { minimumFractionDigits: 2 })}`; type Method = "cash" | "transfer" | "card"; const METHODS: SelectOption[] = [ { value: "cash", label: "Cash" }, { value: "transfer", label: "Bank transfer" }, { value: "card", label: "Card" }, ]; /** Fake persistence for the inline editors — real apps await their mutation. */ function persist(set: (v: T) => void) { return (v: T) => new Promise((resolve) => { setTimeout(() => { set(v); resolve(); }, 350); }); } /** A PICKER'S ANSWER, for a field the record stores as a string. Every picker * can answer "nothing" — that is what the empty row and an emptied entry * commit — so a caller whose value has no null takes the empty value for it. */ const stated = (set: (v: string) => void) => (v: string | null) => persist(set)(v ?? ""); const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg=="; const MOCK_PHOTO_URL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM5M2M1ZmQnLz48Y2lyY2xlIGN4PScyNDAnIGN5PSc4MCcgcj0nNDAnIGZpbGw9JyNmZWY5YzMnLz48L3N2Zz4="; // ── the FEES ledger — the DETAILED money view (both directions), distinct // from Billing's invoice DOCUMENTS: every fee the record incurs or charges, // with its party, due date, proof reference and paid state. // // The drill-down law for a CHILD COLLECTION inside a record: the row EXPANDS // (`TableRow` `detail` + `expanded`), it does not open a drawer. A register // screen's row still docks a workspace Drawer — on a list screen there is // nowhere else for the detail to go — but here the record page IS the context // the drawer was recreating, so docking one charged for navigation twice and // took the surrounding rows away to show nine fields. // // This is also why the ledger is not simply WIDER. All nine fields as columns // demand 946px + 112 gap + 40 gutter = 1098px against a reading column a third // of that; over budget `table_fit` drops droppable columns by priority until // the rest fit. Widening the register does not display VAT / Invoice no / Note // — it drops them silently. And `note` is prose: it has no column width at any // measure. type FeeDirection = "charge" | "cost"; /** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns * the stored object; the object URL stands in for that here. */ let feeDocSeq = 0; const asDisplayFile = (f: File): DisplayFile => ({ id: `fee-doc-u${(feeDocSeq += 1)}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f), }); interface Fee { id: string; name: string; /** charge = billed to the customer, cost = paid out to a vendor. */ direction: FeeDirection; party: string; amount: number; vat: number | null; due: string; invoiceNo: string; /** The supplier's original, filed against THIS fee. A number is what the field * claimed; these are what actually arrived, and the gap between them is the * work a payables desk does. */ docs: DisplayFile[]; paid: boolean; note: string; } let feeSeq = 4; const FEE_SEED: Fee[] = [ { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" }, { id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", docs: [{ id: "fee-doc-1", filename: "NH-2044-invoice.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], paid: false, note: "Last-mile to the port" }, // UNPAID with a number claimed and nothing filed — the state the status column // exists to surface. It was `paid: true`, which meant "Awaiting invoice" could // never render and the design could not be seen at all. { id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", docs: [], paid: false, note: "" }, // A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier // who sends a phone snap has not sent a tax document, and the colour says so // before anyone opens it. A fixture of nothing but PDFs makes the badge look // like decoration, because the one thing it carries never varies. { id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "HF-8821", docs: [{ id: "fee-doc-2", filename: "storage-invoice-photo.jpg", mimeType: "image/jpeg", url: MOCK_PHOTO_URL }], paid: false, note: "3 extra days at Central hub" }, ]; // `priority` = the mobile contract: Party then Type drop first on a narrow // container, Amount + Status survive with the identity; at the floor the rows // STACK label-over-value — and the expansion always carries the full fee. // THREE columns, not five — the register's grammar. A column per FACT spends // width on separation the reader never asked for: the party belongs to the fee's // identity and the payment state belongs to its amount, so each rides line 2 of // the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is // more than the document column costs — the table gained information and lost // two columns. const FEE_COLUMNS: TableColumn[] = [ { key: "fee", label: "Fee", flex: 1 }, { key: "amount", label: "Amount", width: 104, align: "right", priority: 1 }, { key: "invoice", label: "Invoice", width: 116, priority: 2 }, ]; const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date(); interface Charge { key: string; label: string; /** The expected list price — shown as ghost text while the line is unset. */ standard: number; amount: number; method: Method | ""; } interface Invoice { key: string; title: string; charges: Charge[]; /** Lookup code once issued to the e-invoice provider; "" = not issued. */ ref: string; } const BILLING_INITIAL: Invoice[] = [ { // Issued AND multi-line, which is the row that exercises the precedence: `peek` wins, // so its lookup link renders INSIDE the popover and the flat `reference` is ignored. // Storage is the mirror case (one charge, issued) and takes the flat link instead. key: "delivery", title: "Delivery", ref: "INV-0029", charges: [ { key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" }, { key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" }, ], }, { key: "handling", title: "Handling", ref: "", charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }], }, { // Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the // band's lookup link, and the ledger's trailing `reference`. A fixture where nothing // has happened yet only ever exercises the first half of a flow. key: "storage", title: "Storage", ref: "INV-0031", charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }], }, ]; /** A settled credit against the record — the statement's third side, and its only line. */ const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" }; const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0); const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method); function InvoiceParticulars({ inv }: { inv: Invoice }) { return ( {inv.title} {inv.charges.map((c) => ( {c.label} 0 ? undefined : "muted"}> {c.amount > 0 ? formatMoney(c.amount) : "—"} ))} {inv.ref ? ( {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref} ) : null} ); } // ── the CHARGE BANDS — `ChargeLines`, the write side. One seed feeds the wide // band and the narrow one, because the fork is a statement about the BAND's // container and nothing else: the same data, the same handlers, two widths. interface PricedLine { key: string; label: string; meta?: string; quantity: number | null; unitPrice: number | null; } const PRICED_SEED: PricedLine[] = [ { key: "runs", label: "Delivery runs", meta: "Distance band 101–200 km", quantity: 45, unitPrice: 4_500 }, { key: "cleaning", label: "Site cleaning", quantity: 1, unitPrice: 30_000 }, ]; /** A line's problem, derived from the line rather than stored beside it — and * seeded TRUE, so the band renders one in both forks. A warning nothing on the * page can reach is a slot nobody measures. Scoped to the line the rate card * actually prices, which is the same line its Ceiling verb is enabled on. */ const overCeiling = (l: PricedLine) => l.key === "runs" && (l.unitPrice ?? 0) > CEILING_RATE ? `Above the rate card's ${money(CEILING_RATE)} ceiling` : undefined; /** The rate card's ceiling — the figure the one-tap verb fills in. */ const CEILING_RATE = 4_200; const lineAmount = (l: PricedLine) => (l.quantity ?? 0) * (l.unitPrice ?? 0); export function TplMoney() { // ONE clock per render. Two rows each calling `new Date()` can land on // opposite sides of midnight and disagree about what "today" is. const now = new Date(); const words = useLoticsLocale(); // The countdown for any date field on this record: "2 days overdue" / "Today" // / "5 days left", routed to the annotation slot its urgency earns // (error / warning / description) — @lotics/ui/deadline owns the wording and // the thresholds so this screen cannot disagree with the register beside it. const dueAnnotation = (iso: string, open: boolean) => open && iso !== "" ? deadlineAnnotation(daysUntil(new Date(iso), now), words.deadline) : {}; // ── the fees ledger — a register whose rows EXPAND (create-then-refine: Add // appends a blank fee already open; every field refines in the expansion) const [fees, setFees] = useState(FEE_SEED); // Which fee is expanded — ONE at a time, the caller's rule (`TableRow.expanded` // is controlled precisely so this is a decision and not a default). Two open // 380px details would push the third row off screen, and a register that can't // be scanned has stopped being a register. const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null); const patchFee = (id: string, p: Partial) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f))); const addFee = () => { const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" }; setFees((prev) => [...prev, f]); setFeeView({ kind: "edit", id: f.id }); }; const deleteFee = (f: Fee) => { Alert.confirm({ title: `Remove ${f.name || "this fee"}?`, message: "The fee comes off the record's ledger.", confirmLabel: "Remove fee", onConfirm: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); }, }); }; // The status column speaks in facts: Paid, Overdue, Due , Unpaid. /** * MONEY state only — it rides under the amount it is about. * * "Awaiting docs" lived here while the document had no column of its own, and * it was a conflation: whether a supplier's original has arrived is not a fact * about the money, and putting it in the money's state meant a fee could not * report both at once. Now the Invoice column carries the document and this * carries the payment, and each says one thing. */ const feeStatus = (f: Fee): { text: string; danger: boolean } => f.paid ? { text: "Paid", danger: false } : feeOverdue(f) ? { text: "Overdue", danger: true } : f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false } : { text: "Unpaid", danger: false }; // The gallery is mounted only while a file is being looked at. The row's link // opens the fee's OWN originals — the number in that cell is what they were // filed against, so the preview a reader asks for by pressing it is that fee's // paperwork and not the record's whole pile. const [preview, setPreview] = useState<{ files: DisplayFile[]; index: number } | null>(null); // ── billing — the invoice documents const [invoices, setInvoices] = useState(BILLING_INITIAL); const [deposit, setDeposit] = useState(0); const [confirmIssueKey, setConfirmIssueKey] = useState(null); const seq = useRef(414); const patchCharge = (invKey: string, chKey: string, patch: Partial) => setInvoices((prev) => prev.map((inv) => inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) }, ), ); /** Inline-editor persistence for one charge cell. */ const saveCharge = (invKey: string, chKey: string, patch: Partial) => new Promise((resolve) => { setTimeout(() => { patchCharge(invKey, chKey, patch); resolve(); }, 350); }); // Resolved from the CURRENT invoices every render — the confirm dialog quotes the // total that will actually be billed, including a charge that landed after the press. const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null; const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0); // A charge carrying a METHOD has been paid — the statement below is derived from // that, not from a second "paid" flag somebody has to keep in step with it. The // record already knows; asking again is how the two answers start disagreeing. // // ONE list, and both the rows and the subtotal read it. Deriving them from two // separate predicates is how a ledger comes to disagree with itself: filter the // rows on `method && amount > 0` while summing on `method` alone and any charge // that fails only the second test lands in the total with no line to explain it. // The component cannot catch that — it renders the `total` it is handed. const receipts = invoices.flatMap((inv) => inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })), ); const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0); // Three sides now, so the total states the arithmetic rather than a difference of two: // what was billed, less what was credited, less what arrived. const outstanding = grandTotal - CREDIT.amount - received; // The soonest deadline still open, which is the only one a countdown can be // about: a band cell states ONE date, and the record is working to the nearest. const nextDue = fees .filter((f) => !f.paid && f.due !== "") .map((f) => f.due) .sort()[0] ?? null; const allMissing = invoices.flatMap(missingMethods); const issue = (inv: Invoice) => { seq.current += 1; const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`; setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x))); setConfirmIssueKey(null); }; const printReceipt = () => { if (allMissing.length > 0) { Alert.alert( "Missing payment method", `Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`, [{ text: "OK" }], ); return; } Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]); }; // ── the charge bands const [pricedLines, setPricedLines] = useState(PRICED_SEED); const patchLine = (key: string, p: Partial) => setPricedLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...p } : l))); const removeLine = (key: string) => setPricedLines((prev) => prev.filter((l) => l.key !== key)); const pricedTotal = pricedLines.reduce((s, l) => s + lineAmount(l), 0); const [handling, setHandling] = useState(0); const [clearance, setClearance] = useState(1_200); const [handlingMethod, setHandlingMethod] = useState(null); const [mixedQty, setMixedQty] = useState(45); const [mixedRate, setMixedRate] = useState(4_000); const [shortfall, setShortfall] = useState(50_000); /** One priced band, rendered twice — see the narrow section for why. */ const pricedBand = ( } /* The commit takes its OWN row at the band's end and sits on the left — it belongs to the band, so the band draws it rather than the page laying a button beside one. */ action={