@lotics/ui 46.3.0 → 46.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1027 @@
1
+ import { useRef, useState } from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { Alert } from "@lotics/ui/alert";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { IconButton } from "@lotics/ui/icon_button";
7
+ import { Link } from "@lotics/ui/link";
8
+ import { TextLink } from "@lotics/ui/text_link";
9
+ import { Callout, CalloutText } from "@lotics/ui/callout";
10
+ import { PageContent } from "@lotics/ui/page_content";
11
+ import { Section, SectionHeading, SectionHeadingTitle, Subsection } from "@lotics/ui/section_heading";
12
+ import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
13
+ import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
14
+ import { SummaryLine } from "@lotics/ui/summary_line";
15
+ import { EmptyState } from "@lotics/ui/empty_state";
16
+ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
17
+ import { FileBadge } from "@lotics/ui/file_badge";
18
+ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
19
+ import { type DisplayFile } from "@lotics/ui/file_thumbnail";
20
+ import { InlineButton } from "@lotics/ui/inline_button";
21
+ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
22
+ import { InlineFiles } from "@lotics/ui/inline_files";
23
+ import { InlineNumberInput } from "@lotics/ui/inline_number_input";
24
+ import { InlineSelect } from "@lotics/ui/inline_select";
25
+ import { InlineTextInput } from "@lotics/ui/inline_text_input";
26
+ import { ChargeLine, ChargeLines } from "@lotics/ui/charge_lines";
27
+ import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
28
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
29
+ import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
30
+ import { formatMoney } from "@lotics/ui/format_money";
31
+ import { useLoticsLocale } from "@lotics/ui/locale";
32
+ import type { PickerOption } from "@lotics/ui/picker";
33
+
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ // Template, Money — THE money surface: one record's money, in every shape it
36
+ // takes. Four kinds of screen live here and they are not interchangeable:
37
+ //
38
+ // FEES — the DETAILED view, both directions (charge/cost), each fee with
39
+ // its party, due date, supplier original and paid state. A
40
+ // register whose every row expands in place to its full detail.
41
+ // BILLING — the invoice DOCUMENTS: per-invoice charge bands, the closing
42
+ // `Ledger` statement, the deposit and the receipt.
43
+ // CHARGES — `ChargeLines`, the editable band you PRICE into: priced lines,
44
+ // flat amounts, mixed kinds, locked, empty, and the narrow fork.
45
+ //
46
+ // The split that governs the page: `ChargeLines` is what you are PRICING and
47
+ // `Ledger` is what you are READING — a band you type into versus a statement
48
+ // you check. Both keep every figure on one right-aligned tabular column,
49
+ // because a money column that lands on more edges than it has kinds cannot be
50
+ // added up by eye.
51
+ //
52
+ // Fees and Billing are lifted from `tpl_record`, where they are two sections of
53
+ // a much larger record surface; here they are the subject rather than a part of
54
+ // one, and the charge bands below them show the shapes a record's money takes
55
+ // that no single scenario exercises at once.
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ /** ONE ₫ formatter for the page — a template that spells its home currency two
59
+ * ways teaches two ways. `formatMoney` is the kit's, so a table cell and a
60
+ * charge line cannot disagree about digit grouping. */
61
+ const money = (v: number | null) => (v == null ? "" : formatMoney(v));
62
+ /** A SECOND currency, for the one band that is denominated in it. A band formats
63
+ * its own money (`ChargeLines.formatMoney`), which is exactly what lets a
64
+ * foreign-currency charge sit on the same page as the domestic ones. */
65
+ const eur = (n: number) => `€${n.toLocaleString("en-IE", { minimumFractionDigits: 2 })}`;
66
+
67
+ type Method = "cash" | "transfer" | "card";
68
+ const METHODS: PickerOption<Method>[] = [
69
+ { value: "cash", label: "Cash" },
70
+ { value: "transfer", label: "Bank transfer" },
71
+ { value: "card", label: "Card" },
72
+ ];
73
+
74
+ /** Fake persistence for the inline editors — real apps await their mutation. */
75
+ function persist<T>(set: (v: T) => void) {
76
+ return (v: T) =>
77
+ new Promise<void>((resolve) => {
78
+ setTimeout(() => {
79
+ set(v);
80
+ resolve();
81
+ }, 350);
82
+ });
83
+ }
84
+
85
+ const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
86
+ const MOCK_PHOTO_URL =
87
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM5M2M1ZmQnLz48Y2lyY2xlIGN4PScyNDAnIGN5PSc4MCcgcj0nNDAnIGZpbGw9JyNmZWY5YzMnLz48L3N2Zz4=";
88
+
89
+ // ── the FEES ledger — the DETAILED money view (both directions), distinct
90
+ // from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
91
+ // with its party, due date, proof reference and paid state.
92
+ //
93
+ // The drill-down law for a CHILD COLLECTION inside a record: the row EXPANDS
94
+ // (`TableRow` `detail` + `expanded`), it does not open a drawer. A register
95
+ // screen's row still docks a workspace Drawer — on a list screen there is
96
+ // nowhere else for the detail to go — but here the record page IS the context
97
+ // the drawer was recreating, so docking one charged for navigation twice and
98
+ // took the surrounding rows away to show nine fields.
99
+ //
100
+ // This is also why the ledger is not simply WIDER. All nine fields as columns
101
+ // demand 946px + 112 gap + 40 gutter = 1098px against a reading column a third
102
+ // of that; over budget `table_fit` drops droppable columns by priority until
103
+ // the rest fit. Widening the register does not display VAT / Invoice no / Note
104
+ // — it drops them silently. And `note` is prose: it has no column width at any
105
+ // measure.
106
+ type FeeDirection = "charge" | "cost";
107
+ /** A picked `File` as the kit's `DisplayFile`. A real host uploads and returns
108
+ * the stored object; the object URL stands in for that here. */
109
+ let feeDocSeq = 0;
110
+ const asDisplayFile = (f: File): DisplayFile => ({
111
+ id: `fee-doc-u${(feeDocSeq += 1)}`,
112
+ filename: f.name,
113
+ mimeType: f.type || "application/octet-stream",
114
+ url: URL.createObjectURL(f),
115
+ });
116
+
117
+ interface Fee {
118
+ id: string;
119
+ name: string;
120
+ /** charge = billed to the customer, cost = paid out to a vendor. */
121
+ direction: FeeDirection;
122
+ party: string;
123
+ amount: number;
124
+ vat: number | null;
125
+ due: string;
126
+ invoiceNo: string;
127
+ /** The supplier's original, filed against THIS fee. A number is what the field
128
+ * claimed; these are what actually arrived, and the gap between them is the
129
+ * work a payables desk does. */
130
+ docs: DisplayFile[];
131
+ paid: boolean;
132
+ note: string;
133
+ }
134
+ let feeSeq = 4;
135
+ const FEE_SEED: Fee[] = [
136
+ { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", docs: [], paid: true, note: "" },
137
+ { 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" },
138
+ // UNPAID with a number claimed and nothing filed — the state the status column
139
+ // exists to surface. It was `paid: true`, which meant "Awaiting invoice" could
140
+ // never render and the design could not be seen at all.
141
+ { 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: "" },
142
+ // A PHOTO of an invoice, not a PDF — the case the badge exists for. A supplier
143
+ // who sends a phone snap has not sent a tax document, and the colour says so
144
+ // before anyone opens it. A fixture of nothing but PDFs makes the badge look
145
+ // like decoration, because the one thing it carries never varies.
146
+ { 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" },
147
+ ];
148
+ // `priority` = the mobile contract: Party then Type drop first on a narrow
149
+ // container, Amount + Status survive with the identity; at the floor the rows
150
+ // STACK label-over-value — and the expansion always carries the full fee.
151
+ // THREE columns, not five — the register's grammar. A column per FACT spends
152
+ // width on separation the reader never asked for: the party belongs to the fee's
153
+ // identity and the payment state belongs to its amount, so each rides line 2 of
154
+ // the cell it qualifies. Grouping bought 246px (Party 150 + Status 96), which is
155
+ // more than the document column costs — the table gained information and lost
156
+ // two columns.
157
+ const FEE_COLUMNS: TableColumn[] = [
158
+ { key: "fee", label: "Fee", flex: 1 },
159
+ { key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
160
+ { key: "invoice", label: "Invoice", width: 116, priority: 2 },
161
+ ];
162
+ const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
163
+
164
+ interface Charge {
165
+ key: string;
166
+ label: string;
167
+ /** The expected list price — shown as ghost text while the line is unset. */
168
+ standard: number;
169
+ amount: number;
170
+ method: Method | "";
171
+ }
172
+
173
+ interface Invoice {
174
+ key: string;
175
+ title: string;
176
+ charges: Charge[];
177
+ /** Lookup code once issued to the e-invoice provider; "" = not issued. */
178
+ ref: string;
179
+ }
180
+
181
+ const BILLING_INITIAL: Invoice[] = [
182
+ {
183
+ // Issued AND multi-line, which is the row that exercises the precedence: `peek` wins,
184
+ // so its lookup link renders INSIDE the popover and the flat `reference` is ignored.
185
+ // Storage is the mirror case (one charge, issued) and takes the flat link instead.
186
+ key: "delivery",
187
+ title: "Delivery",
188
+ ref: "INV-0029",
189
+ charges: [
190
+ { key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
191
+ { key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
192
+ ],
193
+ },
194
+ {
195
+ key: "handling",
196
+ title: "Handling",
197
+ ref: "",
198
+ charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
199
+ },
200
+ {
201
+ // Seeded ISSUED, so the section renders both states at rest: the Re-issue path, the
202
+ // band's lookup link, and the ledger's trailing `reference`. A fixture where nothing
203
+ // has happened yet only ever exercises the first half of a flow.
204
+ key: "storage",
205
+ title: "Storage",
206
+ ref: "INV-0031",
207
+ charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
208
+ },
209
+ ];
210
+
211
+ /** A settled credit against the record — the statement's third side, and its only line. */
212
+ const CREDIT = { key: "cn-0031", label: "Credit note CN-0031", amount: 120_000, meta: "Storage waived, 3 days" };
213
+
214
+ const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
215
+ const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
216
+
217
+ function InvoiceParticulars({ inv }: { inv: Invoice }) {
218
+ return (
219
+ <View style={{ gap: 8, padding: 12, minWidth: 240 }}>
220
+ <Text size="xs" color="muted">{inv.title}</Text>
221
+ {inv.charges.map((c) => (
222
+ <View key={c.key} style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
223
+ <Text size="sm" style={{ flexGrow: 1, flexShrink: 1 }}>{c.label}</Text>
224
+ <Text size="sm" tabular color={c.amount > 0 ? undefined : "muted"}>
225
+ {c.amount > 0 ? formatMoney(c.amount) : "—"}
226
+ </Text>
227
+ </View>
228
+ ))}
229
+ {inv.ref ? (
230
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
231
+ ) : null}
232
+ </View>
233
+ );
234
+ }
235
+
236
+ // ── the CHARGE BANDS — `ChargeLines`, the write side. One seed feeds the wide
237
+ // band and the narrow one, because the fork is a statement about the BAND's
238
+ // container and nothing else: the same data, the same handlers, two widths.
239
+ interface PricedLine {
240
+ key: string;
241
+ label: string;
242
+ meta?: string;
243
+ quantity: number | null;
244
+ unitPrice: number | null;
245
+ }
246
+ const PRICED_SEED: PricedLine[] = [
247
+ { key: "runs", label: "Delivery runs", meta: "Distance band 101–200 km", quantity: 45, unitPrice: 4_000 },
248
+ { key: "cleaning", label: "Site cleaning", quantity: 1, unitPrice: 30_000 },
249
+ ];
250
+ /** The rate card's ceiling — the figure the one-tap verb fills in. */
251
+ const CEILING_RATE = 4_200;
252
+ const lineAmount = (l: PricedLine) => (l.quantity ?? 0) * (l.unitPrice ?? 0);
253
+
254
+ export function TplMoney() {
255
+ // ONE clock per render. Two rows each calling `new Date()` can land on
256
+ // opposite sides of midnight and disagree about what "today" is.
257
+ const now = new Date();
258
+ const words = useLoticsLocale();
259
+ // The countdown for any date field on this record: "2 days overdue" / "Today"
260
+ // / "5 days left", routed to the annotation slot its urgency earns
261
+ // (error / warning / description) — @lotics/ui/deadline owns the wording and
262
+ // the thresholds so this screen cannot disagree with the register beside it.
263
+ const dueAnnotation = (iso: string, open: boolean) =>
264
+ open && iso !== "" ? deadlineAnnotation(daysUntil(new Date(iso), now), words.deadline) : {};
265
+
266
+ // ── the fees ledger — a register whose rows EXPAND (create-then-refine: Add
267
+ // appends a blank fee already open; every field refines in the expansion)
268
+ const [fees, setFees] = useState<Fee[]>(FEE_SEED);
269
+ // Which fee is expanded — ONE at a time, the caller's rule (`TableRow.expanded`
270
+ // is controlled precisely so this is a decision and not a default). Two open
271
+ // 380px details would push the third row off screen, and a register that can't
272
+ // be scanned has stopped being a register.
273
+ const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
274
+ const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
275
+ const addFee = () => {
276
+ const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", docs: [], paid: false, note: "" };
277
+ setFees((prev) => [...prev, f]);
278
+ setFeeView({ kind: "edit", id: f.id });
279
+ };
280
+ const deleteFee = (f: Fee) => {
281
+ Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
282
+ { text: "Cancel", style: "cancel" },
283
+ { text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); } },
284
+ ]);
285
+ };
286
+ // The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
287
+ /**
288
+ * MONEY state only — it rides under the amount it is about.
289
+ *
290
+ * "Awaiting docs" lived here while the document had no column of its own, and
291
+ * it was a conflation: whether a supplier's original has arrived is not a fact
292
+ * about the money, and putting it in the money's state meant a fee could not
293
+ * report both at once. Now the Invoice column carries the document and this
294
+ * carries the payment, and each says one thing.
295
+ */
296
+ const feeStatus = (f: Fee): { text: string; danger: boolean } =>
297
+ f.paid ? { text: "Paid", danger: false }
298
+ : feeOverdue(f) ? { text: "Overdue", danger: true }
299
+ : f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
300
+ : { text: "Unpaid", danger: false };
301
+
302
+ // The gallery is mounted only while a file is being looked at. The row's link
303
+ // opens the fee's OWN originals — the number in that cell is what they were
304
+ // filed against, so the preview a reader asks for by pressing it is that fee's
305
+ // paperwork and not the record's whole pile.
306
+ const [preview, setPreview] = useState<{ files: DisplayFile[]; index: number } | null>(null);
307
+
308
+ // ── billing — the invoice documents
309
+ const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
310
+ const [deposit, setDeposit] = useState(0);
311
+ const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
312
+ const seq = useRef(414);
313
+
314
+ const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
315
+ setInvoices((prev) =>
316
+ prev.map((inv) =>
317
+ inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
318
+ ),
319
+ );
320
+
321
+ /** Inline-editor persistence for one charge cell. */
322
+ const saveCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
323
+ new Promise<void>((resolve) => {
324
+ setTimeout(() => {
325
+ patchCharge(invKey, chKey, patch);
326
+ resolve();
327
+ }, 350);
328
+ });
329
+
330
+ // Resolved from the CURRENT invoices every render — the confirm dialog quotes the
331
+ // total that will actually be billed, including a charge that landed after the press.
332
+ const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
333
+ const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
334
+ // A charge carrying a METHOD has been paid — the statement below is derived from
335
+ // that, not from a second "paid" flag somebody has to keep in step with it. The
336
+ // record already knows; asking again is how the two answers start disagreeing.
337
+ //
338
+ // ONE list, and both the rows and the subtotal read it. Deriving them from two
339
+ // separate predicates is how a ledger comes to disagree with itself: filter the
340
+ // rows on `method && amount > 0` while summing on `method` alone and any charge
341
+ // that fails only the second test lands in the total with no line to explain it.
342
+ // The component cannot catch that — it renders the `total` it is handed.
343
+ const receipts = invoices.flatMap((inv) =>
344
+ inv.charges.filter((c) => c.method !== "" && c.amount > 0).map((c) => ({ inv, charge: c })),
345
+ );
346
+ const received = receipts.reduce((sum, r) => sum + r.charge.amount, 0);
347
+ // Three sides now, so the total states the arithmetic rather than a difference of two:
348
+ // what was billed, less what was credited, less what arrived.
349
+ const outstanding = grandTotal - CREDIT.amount - received;
350
+ const allMissing = invoices.flatMap(missingMethods);
351
+
352
+ const issue = (inv: Invoice) => {
353
+ seq.current += 1;
354
+ const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
355
+ setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
356
+ setConfirmIssueKey(null);
357
+ };
358
+
359
+ const printReceipt = () => {
360
+ if (allMissing.length > 0) {
361
+ Alert.alert(
362
+ "Missing payment method",
363
+ `Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
364
+ [{ text: "OK" }],
365
+ );
366
+ return;
367
+ }
368
+ Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
369
+ };
370
+
371
+ // ── the charge bands
372
+ const [pricedLines, setPricedLines] = useState<PricedLine[]>(PRICED_SEED);
373
+ const patchLine = (key: string, p: Partial<PricedLine>) =>
374
+ setPricedLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...p } : l)));
375
+ const removeLine = (key: string) => setPricedLines((prev) => prev.filter((l) => l.key !== key));
376
+ const pricedTotal = pricedLines.reduce((s, l) => s + lineAmount(l), 0);
377
+
378
+ const [handling, setHandling] = useState<number | null>(0);
379
+ const [clearance, setClearance] = useState<number | null>(1_200);
380
+ const [handlingMethod, setHandlingMethod] = useState<Method | null>(null);
381
+
382
+ const [mixedQty, setMixedQty] = useState<number | null>(45);
383
+ const [mixedRate, setMixedRate] = useState<number | null>(4_000);
384
+ const [shortfall, setShortfall] = useState<number | null>(50_000);
385
+
386
+ /** One priced band, rendered twice — see the narrow section for why. */
387
+ const pricedBand = (
388
+ <ChargeLines
389
+ totalLabel="Total"
390
+ total={pricedTotal}
391
+ formatMoney={money}
392
+ empty={<EmptyState compact message="Nothing priced yet" hint="Add a charge to price it against the rate card." />}
393
+ /* The commit takes its OWN row at the band's end and sits on the left —
394
+ it belongs to the band, so the band draws it rather than the page
395
+ laying a button beside one. */
396
+ action={
397
+ <Button
398
+ color="primary"
399
+ title="Issue invoice"
400
+ disabled={pricedTotal <= 0}
401
+ onPress={() => Alert.alert("Issue invoice", `Issuing an invoice for ${money(pricedTotal)}.`, [{ text: "OK" }])}
402
+ />
403
+ }
404
+ >
405
+ {pricedLines.map((l) => (
406
+ <ChargeLine
407
+ key={l.key}
408
+ label={l.label}
409
+ meta={l.meta}
410
+ quantity={l.quantity}
411
+ unitPrice={l.unitPrice}
412
+ onQuantityChange={(v) => patchLine(l.key, { quantity: v })}
413
+ onUnitPriceChange={(v) => patchLine(l.key, { unitPrice: v })}
414
+ /* the rate card's ceiling, one tap, BESIDE the field it fills */
415
+ /* Unconditional + `disabled`, never conditional: a verb passed only when it
416
+ has work makes the field narrower on the rows that carry it, so those rows
417
+ stop sharing this money column with the rest. */
418
+ unitPriceActions={
419
+ <InlineButton
420
+ title={money(CEILING_RATE)}
421
+ accessibilityLabel="Apply the ceiling rate"
422
+ disabled={l.key !== "runs" || l.unitPrice === CEILING_RATE}
423
+ onPress={() => patchLine(l.key, { unitPrice: CEILING_RATE })}
424
+ />
425
+ }
426
+ action={<IconButton icon="x" size="sm" color="danger" tooltip="Remove this charge" onPress={() => removeLine(l.key)} />}
427
+ />
428
+ ))}
429
+ </ChargeLines>
430
+ );
431
+
432
+ return (
433
+ <PageContent
434
+ size="md"
435
+ title="Money"
436
+ description="Everything this record charges, costs, bills and collects."
437
+ >
438
+ <SectionStack>
439
+ <Section>
440
+ <SectionHeading>
441
+ <SectionHeadingTitle description="Every fee the record incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
442
+ {/* THE SECTION'S ADD RIDES THE HEADING ROW, right edge — the one
443
+ place it can sit that does not MOVE. Under the register it sat
444
+ below the last row, so where a reader looks for "how do I add
445
+ one" depended on how many there already were: past a screenful
446
+ the verb is off-screen entirely, and on an empty list there is no
447
+ last row to sit under, so it had to be a SECOND button inside the
448
+ `EmptyState`. One verb, two renderings, neither findable without
449
+ scanning. The heading row is the section's control line — where a
450
+ section's meta and verbs sit, with the title growing to push its
451
+ siblings right — so the add belongs on it, in the same spot
452
+ whether the list holds nought or forty. */}
453
+ <Button title="Add fee" color="primary" onPress={addFee} />
454
+ </SectionHeading>
455
+ <SummaryLine
456
+ items={[
457
+ { label: "collected", value: fees.filter((f) => f.direction === "charge" && f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true },
458
+ { label: "to collect", value: fees.filter((f) => f.direction === "charge" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true },
459
+ { label: "to pay", value: fees.filter((f) => f.direction === "cost" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true, tone: fees.some((f) => f.direction === "cost" && feeOverdue(f)) ? "warning" : undefined },
460
+ ]}
461
+ />
462
+ {/* The empty state takes NO `action` — the heading's Add is the only
463
+ one, and it is already on screen. Repeating the section's verb here
464
+ puts two buttons for one act in view at once, and makes the add MOVE
465
+ the moment the first row lands. The empty state says what the
466
+ section holds; the heading says how to fill it. */}
467
+ {fees.length === 0 ? (
468
+ <EmptyState icon="receipt" message="No fees on this record" hint="Add the first charge or cost — it expands ready to fill in." />
469
+ ) : (
470
+ <Table columns={FEE_COLUMNS}>
471
+ {fees.map((f) => {
472
+ const st = feeStatus(f);
473
+ return (
474
+ <TableRow
475
+ key={f.id}
476
+ /* TOGGLE, unlike a peek's open-never-toggle: a popover
477
+ fights its own outside-press dismissal, an expansion has
478
+ none, so the row that opened it must also close it. */
479
+ onPress={() => setFeeView((cur) => (cur?.id === f.id ? null : { kind: "edit", id: f.id }))}
480
+ expanded={feeView?.id === f.id}
481
+ /* `— details`, the peek's convention: the door is a disclosure,
482
+ not a trip, so "Open" would name the wrong act. `PressDoor`
483
+ carries the state as aria-expanded. */
484
+ accessibilityLabel={`${f.name || "New fee"} — details`}
485
+ /* NO `selected`: `TableRow` already derives it —
486
+ `selected={selected || showDetail}` — so the expanded row
487
+ wears the wash without being told, and passing it here
488
+ would only restate the kit. */
489
+ detail={
490
+ /* The detail carries ALL NINE fields, including the three the
491
+ row already shows — because the row's cells are read-only
492
+ Text, and because tier 2 DROPS columns on a narrow
493
+ container. `table_fit` licenses that drop on the promise
494
+ that "the door opens the record where dropped values
495
+ live"; the expansion is now that door, so anything it
496
+ omitted would be unreachable, not merely inconvenient.
497
+ Table does not expose its fit result, so this cannot be
498
+ conditional — nor should it be. */
499
+ /* 24 (`SPACE.lg`), not the 12 this file uses for intra-block
500
+ gaps: the field grid's own row gap is `SPACE.md` (16), so
501
+ anything separating a DIFFERENT block has to beat it or
502
+ the verb below reads as one more field. Same rung logic
503
+ `DetailRow` uses for its stacked rows. */
504
+ <View style={{ gap: 24 }}>
505
+ {/* 150, the SAME width every field grid on this page uses.
506
+ The page's invariant is one pixel-aligned VALUE column,
507
+ and a `Table`'s detail is inset by `ROW_GUTTER` — which
508
+ is 0 — so this grid starts at the same x as Billing's
509
+ and takes the same label width. In a grid that IS
510
+ inset, the label width is 150 − inset. */}
511
+ <DetailTable labelWidth={150}>
512
+ <DetailRow label="Fee">
513
+ <InlineTextInput value={f.name} onSave={persist((v: string) => patchFee(f.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
514
+ </DetailRow>
515
+ <DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
516
+ <InlineSelect
517
+ value={f.direction}
518
+ options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
519
+ onSave={persist((v: FeeDirection) => patchFee(f.id, { direction: v }))}
520
+ accessibilityLabel="Fee type"
521
+ />
522
+ </DetailRow>
523
+ <DetailRow label="Party">
524
+ <InlineTextInput value={f.party} onSave={persist((v: string) => patchFee(f.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
525
+ </DetailRow>
526
+ <DetailRow label="Amount">
527
+ <InlineNumberInput value={f.amount || null} onSave={persist((v: number | null) => patchFee(f.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
528
+ </DetailRow>
529
+ <DetailRow label="VAT (%)">
530
+ <InlineNumberInput value={f.vat} onSave={persist((v: number | null) => patchFee(f.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
531
+ </DetailRow>
532
+ <DetailRow label="Due" {...dueAnnotation(f.due, !f.paid)}>
533
+ <InlineDatePicker value={f.due} onSave={persist((v: string) => patchFee(f.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
534
+ </DetailRow>
535
+ <DetailRow label="Invoice no" description="The party's invoice or debit note">
536
+ <InlineTextInput value={f.invoiceNo} onSave={persist((v: string) => patchFee(f.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
537
+ </DetailRow>
538
+ {/* THE FILE, ON THE ROW THAT OWES IT — the shape a
539
+ payables desk actually works in: a table of
540
+ charges, each row expanding to the document that
541
+ justifies it. The register cell above can only
542
+ MARK whether a document is held; the managing
543
+ happens here.
544
+
545
+ `multiple`, because a supplier invoice arrives as
546
+ one PDF or as three scanned pages, and the second
547
+ page must not replace the first.
548
+
549
+ `blockedReason` when no number has been claimed:
550
+ there is nothing to file the scan AGAINST yet, and
551
+ a sentence naming that act beats a disabled button
552
+ that only says no. */}
553
+ <DetailRow label="Supplier original">
554
+ <InlineFiles
555
+ files={f.docs}
556
+ onAdd={(picked) => patchFee(f.id, { docs: [...f.docs, ...picked.map(asDisplayFile)] })}
557
+ onRemove={(doc) => patchFee(f.id, { docs: f.docs.filter((d) => d.id !== doc.id) })}
558
+ addLabel="Attach original"
559
+ addMoreLabel="Add page"
560
+ accept="application/pdf,image/*"
561
+ blockedReason={f.invoiceNo.trim() === "" ? "Enter the invoice number first — the scan is filed against it." : undefined}
562
+ />
563
+ </DetailRow>
564
+ <DetailRow label="Status">
565
+ <InlineSelect
566
+ value={f.paid ? "paid" : "unpaid"}
567
+ options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
568
+ onSave={persist((v: string) => patchFee(f.id, { paid: v === "paid" }))}
569
+ accessibilityLabel="Paid status"
570
+ />
571
+ </DetailRow>
572
+ <DetailRow label="Note">
573
+ <InlineTextInput value={f.note} onSave={persist((v: string) => patchFee(f.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
574
+ </DetailRow>
575
+ </DetailTable>
576
+ {/* The destructive verb, LEFT — the peek footer's convention.
577
+ Solid `danger` per composition rule 9; no trash glyph,
578
+ because "Remove fee" already names the object the icon
579
+ was only decorating.
580
+
581
+ NO Divider. Every hairline a `Table` draws is full-bleed,
582
+ so one inset by ROW_GUTTER puts two hairline lengths in
583
+ the same vertical run — and it repeats what the expanded
584
+ row's wash already says. The row wrapper IS required: a
585
+ `Button` alone in a column View stretches full width. */}
586
+ <View style={{ flexDirection: "row" }}>
587
+ <Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
588
+ </View>
589
+ </View>
590
+ }
591
+ >
592
+ {/* IDENTITY over its PARTY — `tpl_item_list`'s customer cell
593
+ exactly: the thing you scan for on line 1, what qualifies
594
+ it on line 2, in one cell rather than two columns. */}
595
+ <TableCell>
596
+ <View style={{ gap: 2 }}>
597
+ <Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{f.name || "—"}</Text>
598
+ {/* Body size: the subject carries `medium`, which is what
599
+ makes this a pair rather than two peers. */}
600
+ <Text size="sm" color="muted" leading="tight" numberOfLines={1}>
601
+ {f.party || (f.direction === "charge" ? "Charge" : "Cost")}
602
+ </Text>
603
+ </View>
604
+ </TableCell>
605
+ {/* AMOUNT over its PAYMENT state — the register's fee cell,
606
+ same stack, same rungs. The state qualifies the number it
607
+ sits under, so it needs no column and no repeated label. */}
608
+ <TableCell>
609
+ <View style={{ gap: 2, alignItems: "flex-end" }}>
610
+ <Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text>
611
+ <Text size="xs" tabular numberOfLines={1} color={st.danger ? "danger" : "muted"}>{st.text}</Text>
612
+ </View>
613
+ </TableCell>
614
+ {/* THE DOCUMENT, with a column of its own at last.
615
+ Held: a clip + the number, pressable to preview. Claimed
616
+ but not arrived: the number in WARNING tone — the fee
617
+ cannot be paid, and that now reads in the column the fact
618
+ belongs to instead of borrowing the money's state. Never
619
+ claimed (paid in cash on the spot): an em dash, because nothing is
620
+ owed and a permanent warning on every such row trains the
621
+ eye straight past the column. */}
622
+ <TableCell>
623
+ {f.invoiceNo.trim() === "" ? (
624
+ <Text size="sm" color="muted">—</Text>
625
+ ) : f.docs.length === 0 ? (
626
+ <Text size="sm" color="muted" numberOfLines={1}>{f.invoiceNo}</Text>
627
+ ) : (
628
+ <Pressable
629
+ onPress={() => setPreview({ files: f.docs, index: 0 })}
630
+ accessibilityRole="button"
631
+ accessibilityLabel={`Open invoice ${f.invoiceNo}`}
632
+ style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
633
+ >
634
+ <FileBadge mimeType={f.docs[0].mimeType} size={22} />
635
+ <TextLink size="sm">{f.invoiceNo}</TextLink>
636
+ </Pressable>
637
+ )}
638
+ </TableCell>
639
+ </TableRow>
640
+ );
641
+ })}
642
+ </Table>
643
+ )}
644
+ </Section>
645
+
646
+ {/* BILLING — invoice documents on the record, in the inline vocabulary.
647
+ The issue action lives at each band's end. In `tpl_record` a
648
+ record-level premise (no customer, no valid tax ID) also reads here
649
+ as ONE section-scoped Callout, because the action-gating law is: a
650
+ not-ready CTA is DISABLED and a PROBLEM reads as a co-located
651
+ Callout at its scope, once. This page holds no customer, so the only
652
+ problems it can state are band-local — and those ride the CHARGE ROW
653
+ that has them (`warning` on the line missing its method), which is
654
+ both smaller than a callout and more precise than one: it names the
655
+ offender instead of counting offenders. Never prose beside a CTA. */}
656
+ <Section>
657
+ <SectionHeading>
658
+ <SectionHeadingTitle description="Each invoice owns its charge lines — a charge never lives apart from the document it bills on.">Billing</SectionHeadingTitle>
659
+ </SectionHeading>
660
+
661
+ {/* EVERYTHING VISIBLE, and the chrome is what shrinks.
662
+ Three attempts landed here. Inline BANDS were "spread" — but the
663
+ spread was never the data: four charge lines across three invoices
664
+ is nothing. Each band carried a `SubsectionHeading`, its own
665
+ callout and its own action row, so the CHROME was three times the
666
+ content. A register then hid those four lines behind three
667
+ expansions, which trades a real fault for a worse one: you can no
668
+ longer see what you are paying for.
669
+ So the lines all stay, and each invoice costs ONE line of chrome —
670
+ its name, its total, its act. No heading rung, no band callout, no
671
+ separate action row. */}
672
+ <SubsectionStack>
673
+ {invoices.map((inv) => {
674
+ const missing = missingMethods(inv);
675
+ const total = invoiceTotal(inv);
676
+ const ready = total > 0 && missing.length === 0;
677
+ return (
678
+ <Subsection key={inv.key}>
679
+ {/* the invoice's ONE line of chrome: what it is, what it comes
680
+ to, and its reference once issued. No indent under it — the
681
+ lead line's weight and its total already mark the group, and
682
+ the `SubsectionStack` beat separates one from the next, so an
683
+ indent would only push every charge off the page's left edge
684
+ for nothing. */}
685
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
686
+ <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>{inv.title}</Text>
687
+ <Text size="sm" tabular color={total > 0 ? "default" : "muted"}>{total > 0 ? money(total) : "—"}</Text>
688
+ {/* HELD OPEN whether or not this invoice has been issued.
689
+ The reference is a trailing verb, and a trailing verb
690
+ that shows on some rows and not others drags the figure
691
+ beside it: three invoice totals then sit on three edges
692
+ and stop sharing the column the `Ledger` below closes.
693
+ Same law as the fixed action slot in `ChargeLines`. */}
694
+ <View style={{ width: 64, alignItems: "flex-start" }}>
695
+ {inv.ref ? (
696
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
697
+ ) : null}
698
+ </View>
699
+ </View>
700
+ <DetailTable labelWidth={150} minValueWidth={320}>
701
+ {inv.charges.map((c) => (
702
+ <DetailRow
703
+ key={c.key}
704
+ label={c.label}
705
+ warning={c.amount > 0 && !c.method ? "Choose how this was paid" : undefined}
706
+ >
707
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
708
+ <View style={{ flexGrow: 1, flexBasis: 150 }}>
709
+ <InlineNumberInput
710
+ value={c.amount || null}
711
+ onSave={(v) => saveCharge(inv.key, c.key, { amount: v ?? 0 })}
712
+ min={0}
713
+ format={money}
714
+ placeholder="—"
715
+ accessibilityLabel={`Amount for ${c.label}`}
716
+ /* The list price, one tap, ON the field it fills —
717
+ and this is the fork from `ChargeLines`, which
718
+ puts the same verb BESIDE the field. The
719
+ discriminator is what the field sits in: here it
720
+ is a form row whose value column grows, so a verb
721
+ on the field's own surface moves nothing. In a
722
+ right-aligned money COLUMN it would eat the
723
+ field's width and slide the figure off the
724
+ column its neighbours are on. */
725
+ actions={
726
+ <InlineButton
727
+ title={money(c.standard)}
728
+ accessibilityLabel={`Use the standard ${c.label} price`}
729
+ disabled={c.amount > 0 || c.standard <= 0}
730
+ onPress={() => saveCharge(inv.key, c.key, { amount: c.standard })}
731
+ />
732
+ }
733
+ />
734
+ </View>
735
+ <View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
736
+ <InlineSelect
737
+ value={c.method === "" ? null : c.method}
738
+ onSave={(m) => saveCharge(inv.key, c.key, { method: m })}
739
+ options={METHODS}
740
+ placeholder="How paid…"
741
+ disabled={c.amount <= 0}
742
+ accessibilityLabel={`Payment method for ${c.label}`}
743
+ />
744
+ </View>
745
+ </View>
746
+ </DetailRow>
747
+ ))}
748
+ {/* AFTER the lines it bills — a committing act goes where its
749
+ effect lands, which is the end of what it commits, never
750
+ above it. On the control column like every other CTA on
751
+ the page (the form-action alignment law). */}
752
+ <DetailRow label="">
753
+ <View style={{ flexDirection: "row" }}>
754
+ <Button
755
+ title={inv.ref ? "Re-issue" : "Issue invoice"}
756
+ color={inv.ref ? "secondary" : "primary"}
757
+ disabled={!ready}
758
+ onPress={() => setConfirmIssueKey(inv.key)}
759
+ />
760
+ </View>
761
+ </DetailRow>
762
+ </DetailTable>
763
+ </Subsection>
764
+ );
765
+ })}
766
+ </SubsectionStack>
767
+
768
+ {/* THE CLOSING STATEMENT — `Ledger`, the money grammar, and the answer to
769
+ the one question the bands above cannot give: what does this record come
770
+ to, and how much of it has arrived. Each invoice states its own total on
771
+ its lead line, but three totals down the page are three facts the reader
772
+ has to add up, and "is anything still owed" was nowhere on the section.
773
+
774
+ It is a STATEMENT, not another band: no `Subsection`, no heading rung. It
775
+ closes the invoices, so it sits directly under them.
776
+
777
+ THREE sides, which is what makes the total state arithmetic rather than a
778
+ difference: billed, less credited, less received. Money coming back is
779
+ NEGATIVE (the component renders "− ") and `success`-toned, because a
780
+ receipt and a credit are both the good outcome; `zeroLabel` gives a settled
781
+ record "Paid in full" instead of a proud zero.
782
+
783
+ The DEPOSIT is deliberately absent. It is refundable — never part of the
784
+ total to collect — so folding it in would make the closing figure answer a
785
+ different question than the one it is labelled with. A ledger earns trust
786
+ by summing exactly what its label claims.
787
+
788
+ A row opens where there is something to open, and the two ways are not
789
+ interchangeable. `peek` floats an invoice's PARTICULARS — the charges behind
790
+ its one figure — and the lookup link lives INSIDE that popover, never beside
791
+ the trigger, because a button inside a button is invalid. `reference` is the
792
+ flat alternative for a row with nothing to expand: one charge IS its own
793
+ particulars, so Storage carries a trailing link instead of a door.
794
+
795
+ The `Adjustments` group holds ONE row and therefore no `total`: a sum over a
796
+ single line states the same fact twice, at two weights. That is the honest
797
+ fixture rather than a tidy one — real groups are built from data, so some
798
+ arrive with one row. */}
799
+ <Ledger formatValue={money}>
800
+ <LedgerGroup label="Invoiced" total={grandTotal}>
801
+ {invoices.map((inv) => {
802
+ /* NO meta on this side. It briefly carried "awaiting payment method",
803
+ and that was `meta` doing two jobs: on the Received rows it holds a
804
+ neutral QUALIFIER (how the money arrived), here it held a PROBLEM.
805
+ Two unlike things in one treatment is what makes a caption read as
806
+ inconsistent — the size was never the fault.
807
+ The problem also had a home already: the charge row above carries
808
+ `warning="Choose how this was paid"`, on the line with the editor that
809
+ fixes it. A statement's job is the arithmetic, and the arithmetic
810
+ already says it — `Outstanding` IS the unpaid money. Saying it again in
811
+ weaker words adds a second voice for one fact. */
812
+ /* A door where there are PARTICULARS, and the count that decides it is the
813
+ charges, not the priced ones. An invoice whose second line is unpriced is
814
+ exactly the row whose figure looks too small for it, which is the question
815
+ the popover answers — gating on `amount > 0` would hide the door precisely
816
+ when it is most useful. */
817
+ return (
818
+ <LedgerRow
819
+ key={inv.key}
820
+ label={inv.title}
821
+ value={invoiceTotal(inv)}
822
+ peek={inv.charges.length > 1 ? <InvoiceParticulars inv={inv} /> : undefined}
823
+ reference={inv.ref ? { label: inv.ref, onPress: () => {} } : undefined}
824
+ />
825
+ );
826
+ })}
827
+ </LedgerGroup>
828
+ <LedgerGroup label="Adjustments">
829
+ <LedgerRow label={CREDIT.label} meta={CREDIT.meta} value={-CREDIT.amount} tone="success" />
830
+ </LedgerGroup>
831
+ {received > 0 ? (
832
+ <LedgerGroup label="Received" total={-received}>
833
+ {receipts.map(({ inv, charge }) => (
834
+ <LedgerRow
835
+ key={`${inv.key}-${charge.key}`}
836
+ label={charge.label}
837
+ meta={METHODS.find((m) => m.value === charge.method)?.label}
838
+ value={-charge.amount}
839
+ tone="success"
840
+ />
841
+ ))}
842
+ </LedgerGroup>
843
+ ) : null}
844
+ <LedgerTotal label="Outstanding" value={outstanding} tone={outstanding > 0 ? "danger" : "default"} zeroLabel="Paid in full" />
845
+ </Ledger>
846
+
847
+ {/* ONE action row for the section, on the control column. The deposit
848
+ is a field of the record, not a band of its own — its receipt is a
849
+ second act on the same row. */}
850
+ <DetailTable labelWidth={150} minValueWidth={320}>
851
+ <DetailRow label="Deposit" description="Refundable — never part of the total to collect">
852
+ <InlineNumberInput
853
+ value={deposit || null}
854
+ onSave={persist((v: number | null) => setDeposit(v ?? 0))}
855
+ min={0}
856
+ format={money}
857
+ placeholder="—"
858
+ accessibilityLabel="Deposit amount"
859
+ actions={<InlineButton title="Receipt" accessibilityLabel="Deposit receipt" disabled={deposit <= 0} onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])} />}
860
+ />
861
+ </DetailRow>
862
+ <DetailRow label="">
863
+ <View style={{ flexDirection: "row" }}>
864
+ <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
865
+ </View>
866
+ </DetailRow>
867
+ </DetailTable>
868
+ </Section>
869
+
870
+ <Section>
871
+ <SectionHeading>
872
+ <SectionHeadingTitle description="Quantity × unit price derives the amount, and the amount is never typed. The arithmetic reads across one line and ends at the amount: hang the derived figure on a second line and every row doubles in height while the answer lands in no column, so the total below closes nothing.">
873
+ Priced lines
874
+ </SectionHeadingTitle>
875
+ </SectionHeading>
876
+ {/* `unitPriceActions` puts the standard rate one tap away, BESIDE the
877
+ field it fills — inside, the verb eats the field's width and the
878
+ figure slides left, so a row with a one-tap rate and a row without
879
+ stop sharing a column. */}
880
+ {pricedBand}
881
+ </Section>
882
+
883
+ <Section>
884
+ <SectionHeading>
885
+ <SectionHeadingTitle description="Omit the quantity and unit price and the AMOUNT becomes the editable figure — a fee agreed as one number rather than computed from a rate. A line's problem sits beside the control that fixes it, never as a callout for the whole band.">
886
+ Flat charges
887
+ </SectionHeadingTitle>
888
+ </SectionHeading>
889
+ {/* `extra` carries a second control that belongs to the charge itself
890
+ (how it was paid), so it rides the charge's own line instead of
891
+ living in a separate table.
892
+
893
+ A band formats its own money: this one is denominated in euro while
894
+ every other band on the page is in ₫, and neither has to know about
895
+ the other. */}
896
+ <ChargeLines
897
+ totalLabel="Invoice total"
898
+ total={(handling ?? 0) + (clearance ?? 0)}
899
+ formatMoney={eur}
900
+ action={
901
+ <Button
902
+ color="primary"
903
+ title="Issue invoice"
904
+ disabled={!handlingMethod}
905
+ onPress={() => Alert.alert("Issue invoice", `Issuing an invoice for ${eur((handling ?? 0) + (clearance ?? 0))}.`, [{ text: "OK" }])}
906
+ />
907
+ }
908
+ >
909
+ <ChargeLine
910
+ label="Handling"
911
+ amount={handling}
912
+ onAmountChange={setHandling}
913
+ amountActions={
914
+ <InlineButton
915
+ title={eur(250)}
916
+ accessibilityLabel="Use the standard handling price"
917
+ disabled={(handling ?? 0) > 0}
918
+ onPress={() => setHandling(250)}
919
+ />
920
+ }
921
+ warning={(handling ?? 0) > 0 && !handlingMethod ? "Choose how this was paid" : undefined}
922
+ extra={
923
+ <InlineSelect
924
+ value={handlingMethod}
925
+ onSave={setHandlingMethod}
926
+ options={METHODS}
927
+ placeholder="How paid…"
928
+ disabled={(handling ?? 0) <= 0}
929
+ accessibilityLabel="Payment method for handling"
930
+ />
931
+ }
932
+ />
933
+ <ChargeLine label="Customs clearance" amount={clearance} onAmountChange={setClearance} />
934
+ </ChargeLines>
935
+ </Section>
936
+
937
+ <Section>
938
+ <SectionHeading>
939
+ <SectionHeadingTitle description="A band mixing a priced line with a flat one still lands every figure on ONE amount column, which is the whole reason the two shapes share a component rather than each app inventing its own row.">
940
+ Mixed kinds
941
+ </SectionHeadingTitle>
942
+ </SectionHeading>
943
+ <ChargeLines totalLabel="Total" total={(mixedQty ?? 0) * (mixedRate ?? 0) + (shortfall ?? 0)} formatMoney={money}>
944
+ <ChargeLine label="Delivery runs" quantity={mixedQty} unitPrice={mixedRate} onQuantityChange={setMixedQty} onUnitPriceChange={setMixedRate} />
945
+ <ChargeLine label="Shortfall recovered" amount={shortfall} onAmountChange={setShortfall} />
946
+ </ChargeLines>
947
+ </Section>
948
+
949
+ {/* ITS OWN SECTION, not a second band under the one above. A band closes
950
+ on its total, so the next band's first line arriving 8px under that
951
+ total reads as another charge in the same band — two bands stacked
952
+ inside one heading have nothing between them that says where one
953
+ stops. The section beat and the heading are what separate them. */}
954
+ <Section>
955
+ <SectionHeading>
956
+ <SectionHeadingTitle description="Locked turns the editors into values once the money is collected — the band stops being a form and becomes the record of what was charged, with no verb slot drawn.">
957
+ Settled
958
+ </SectionHeadingTitle>
959
+ </SectionHeading>
960
+ <ChargeLines totalLabel="Collected" total={190_000} formatMoney={money}>
961
+ <ChargeLine label="Delivery runs" quantity={40} unitPrice={4_000} locked />
962
+ <ChargeLine label="Site cleaning" quantity={1} unitPrice={30_000} locked />
963
+ </ChargeLines>
964
+ </Section>
965
+
966
+ <Section>
967
+ <SectionHeading>
968
+ <SectionHeadingTitle description="An empty band is a real state, not an error: a record that has not been priced. The total still closes the column, so the shape a reader learns does not change the moment there is data.">
969
+ Nothing priced yet
970
+ </SectionHeadingTitle>
971
+ </SectionHeading>
972
+ <ChargeLines
973
+ totalLabel="Total"
974
+ total={0}
975
+ formatMoney={money}
976
+ empty={<EmptyState compact message="Nothing charged on this record" hint="Add the first charge to price it against the rate card." />}
977
+ />
978
+ </Section>
979
+
980
+ <Section>
981
+ <SectionHeading>
982
+ <SectionHeadingTitle description="Below the band's fork width the name takes its own line and the arithmetic sits under it, still ending on the amount column. That is a two-line row ON PURPOSE, which is a different thing from a derived value that fell off the end of a one-line row — the tell is that the amounts still share an edge with the total.">
983
+ The narrow fork
984
+ </SectionHeadingTitle>
985
+ </SectionHeading>
986
+ {/* The fork is measured on the BAND's own container, never the window:
987
+ this shape lives in drawers as often as on pages. Which is why the
988
+ box below is a fixed 360 — it is the SAME band as the priced
989
+ section above, same state, same handlers, rendered in a narrower
990
+ container. Edit either and both move. */}
991
+ <View style={{ width: 360 }}>{pricedBand}</View>
992
+ </Section>
993
+ </SectionStack>
994
+
995
+ {/* issuing an e-invoice is irreversible — confirm in a Dialog */}
996
+ <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssueKey(null); }}>
997
+ <DialogHeader>
998
+ <DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
999
+ </DialogHeader>
1000
+ <View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
1001
+ <Callout tone="warning">
1002
+ <CalloutText>
1003
+ {confirmIssue
1004
+ ? confirmIssue.ref
1005
+ ? `Re-issue "${confirmIssue.title}" (${formatMoney(invoiceTotal(confirmIssue))}) with the current charges. A NEW lookup code replaces ${confirmIssue.ref}; this can't be undone.`
1006
+ : `Issue a real e-invoice for "${confirmIssue.title}" (${formatMoney(invoiceTotal(confirmIssue))}) to the provider. This writes a lookup code to the record and can't be undone.`
1007
+ : ""}
1008
+ </CalloutText>
1009
+ </Callout>
1010
+ </View>
1011
+ <DialogFooter>
1012
+ <Button title="Cancel" color="secondary" onPress={() => setConfirmIssueKey(null)} />
1013
+ <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
1014
+ </DialogFooter>
1015
+ </Dialog>
1016
+
1017
+ {/* Mounted only while open — its per-file hooks want a stable list. */}
1018
+ {preview ? (
1019
+ <FileGalleryModal
1020
+ files={preview.files}
1021
+ activeIndex={preview.index}
1022
+ onIndexChange={(i) => setPreview((p) => (i == null || !p ? null : { ...p, index: i }))}
1023
+ />
1024
+ ) : null}
1025
+ </PageContent>
1026
+ );
1027
+ }