@lotics/ui 8.0.0 → 9.0.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.
Files changed (48) hide show
  1. package/AGENTS.md +171 -68
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_item_list.tsx +1015 -124
  7. package/examples/tpl_pick.tsx +3 -3
  8. package/examples/tpl_pivot.tsx +1 -1
  9. package/examples/tpl_record.tsx +1354 -0
  10. package/examples/tpl_report.tsx +7 -7
  11. package/examples/tpl_rollup.tsx +6 -6
  12. package/examples/tpl_shifts.tsx +2 -2
  13. package/examples/tpl_statements.tsx +221 -0
  14. package/examples/tpl_stock.tsx +7 -7
  15. package/examples/tpl_task_board.tsx +16 -13
  16. package/examples/tpl_tasks.tsx +15 -28
  17. package/examples/tpl_tower.tsx +2 -2
  18. package/package.json +8 -1
  19. package/src/capture_row.tsx +59 -0
  20. package/src/checklist.tsx +104 -0
  21. package/src/chip.tsx +12 -3
  22. package/src/detail_row.tsx +137 -10
  23. package/src/inline_date_picker.tsx +8 -3
  24. package/src/inline_edit.tsx +40 -10
  25. package/src/inline_member_select.tsx +3 -0
  26. package/src/inline_number_input.tsx +5 -2
  27. package/src/inline_select.tsx +8 -3
  28. package/src/inline_tag_select.tsx +140 -0
  29. package/src/inline_text_input.tsx +5 -2
  30. package/src/inline_time_picker.tsx +5 -2
  31. package/src/ledger.tsx +220 -0
  32. package/src/locale.tsx +21 -0
  33. package/src/progress_bar.tsx +32 -1
  34. package/src/record_summary.tsx +101 -0
  35. package/src/section_heading.tsx +16 -8
  36. package/src/suggestion_chip.tsx +47 -0
  37. package/src/use_section_nav.test.ts +69 -0
  38. package/src/use_section_nav.ts +59 -0
  39. package/examples/tpl_billing.tsx +0 -344
  40. package/examples/tpl_detail.tsx +0 -232
  41. package/examples/tpl_directory.tsx +0 -260
  42. package/examples/tpl_intake.tsx +0 -206
  43. package/examples/tpl_order.tsx +0 -482
  44. package/examples/tpl_quick.tsx +0 -211
  45. package/examples/tpl_record_plain.tsx +0 -259
  46. package/examples/tpl_settings.tsx +0 -178
  47. package/examples/tpl_timeline.tsx +0 -244
  48. package/examples/tpl_wizard.tsx +0 -223
@@ -0,0 +1,1354 @@
1
+ import { useRef, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Divider } from "@lotics/ui/divider";
7
+ import { Badge } from "@lotics/ui/badge";
8
+ import { Link } from "@lotics/ui/link";
9
+ import { Icon } from "@lotics/ui/icon";
10
+ import { Alert } from "@lotics/ui/alert";
11
+ import type { PickerOption } from "@lotics/ui/picker";
12
+ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
+ import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
14
+ import { Callout, CalloutText } from "@lotics/ui/callout";
15
+ import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle } from "@lotics/ui/section_heading";
16
+ import { MenuButton } from "@lotics/ui/menu_button";
17
+ import { Modal, ModalBody, ModalHeader } from "@lotics/ui/modal";
18
+ import { useSectionNav } from "@lotics/ui/use_section_nav";
19
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
20
+ import { formatMoney } from "@lotics/ui/format_money";
21
+ import { RecordSummary } from "@lotics/ui/record_summary";
22
+ import { CheckCircle } from "@lotics/ui/check_circle";
23
+ import { ProgressBar } from "@lotics/ui/progress_bar";
24
+ import { FilterChip } from "@lotics/ui/filter_chip";
25
+ import { CaptureRow } from "@lotics/ui/capture_row";
26
+ import { Checklist, ChecklistRow } from "@lotics/ui/checklist";
27
+ import type { ActionMenuItem } from "@lotics/ui/action_menu";
28
+ import { SuggestionChip } from "@lotics/ui/suggestion_chip";
29
+ import { OptionList } from "@lotics/ui/option_list";
30
+ import { MemberChip } from "@lotics/ui/member_chip";
31
+ import { InlineStatic } from "@lotics/ui/inline_static";
32
+ import { InlineTextInput } from "@lotics/ui/inline_text_input";
33
+ import { InlineNumberInput } from "@lotics/ui/inline_number_input";
34
+ import { InlineSelect } from "@lotics/ui/inline_select";
35
+ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
36
+ import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
37
+ import type { MemberSelectMember } from "@lotics/ui/member_select";
38
+ import { Switch } from "@lotics/ui/switch";
39
+ import { FileDropzone } from "@lotics/ui/file_dropzone";
40
+ import type { DisplayFile } from "@lotics/ui/file_thumbnail";
41
+ import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
42
+ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
43
+ import { DangerZone } from "@lotics/ui/danger_zone";
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Template · Record — THE record surface, create-then-refine taken all the way:
47
+ // "New order" is ONE CLICK (no dialog, no form) — it creates a fresh Draft and
48
+ // everything is refined here, in place. Top → bottom: a `RecordSummary` header
49
+ // (identity · status · the to-collect metric) over its key-fact `DetailTable`,
50
+ // a Details `DetailTable`, the CUSTOMER section (TWO states: attached = the
51
+ // read-only-first card — Tax ID / Contact / City edit inline, and the Fetch
52
+ // button fills contact + city from the tax-ID registry, un-gating invoicing;
53
+ // Remove detaches the link → the find-or-create search, whose custom row opens
54
+ // the create Dialog), BILLING — charges grouped into issuable invoice
55
+ // documents, every value in the inline vocabulary (amount chips with the list
56
+ // price as ghost text, "How paid…" select chips), the issue action in each
57
+ // band's header, ONE section-level callout explaining what blocks issuing, the
58
+ // collect total, and a separate refundable deposit — and FILES (drop to add,
59
+ // gallery preview, delete). The lifecycle is the HANDOFF CHAIN below —
60
+ // per-desk task checklists inform each stage's handoff CTA (never blocking);
61
+ // there is no separate submit step. Also the settings shape:
62
+ // Preferences switch rows + the `DangerZone`.
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ interface Customer {
66
+ id: string;
67
+ name: string;
68
+ code: string;
69
+ taxId: string;
70
+ contact: string;
71
+ city: string;
72
+ }
73
+
74
+ // Atlas ships WITHOUT a tax ID — attach it to see the billing gate + the
75
+ // inline Tax ID fix-up in the Customer section.
76
+ const KNOWN_CUSTOMERS: Customer[] = [
77
+ { id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg" },
78
+ { id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam" },
79
+ { id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore" },
80
+ { id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg" },
81
+ { id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp" },
82
+ ];
83
+
84
+ const TAX_ID_RE = /^\d{10}(\d{3})?$/;
85
+
86
+ // HANDOFF is a STAGE TRANSITION on the shared record — never a message or a
87
+ // task. Each department owns its sections; the handoff CTA moves the record to
88
+ // the next desk (spine + stage = handoff) — open tasks warn, they never block.
89
+ type Stage = "sales" | "operations" | "accounting" | "closed";
90
+ type Desk = Exclude<Stage, "closed">;
91
+ const DESKS: { key: Desk; label: string; color: "blue" | "amber" | "violet" }[] = [
92
+ { key: "sales", label: "Sales", color: "blue" },
93
+ { key: "operations", label: "Operations", color: "amber" },
94
+ { key: "accounting", label: "Accounting", color: "violet" },
95
+ ];
96
+ const STAGES: { key: Stage; label: string; color: "blue" | "amber" | "violet" | "emerald" }[] = [
97
+ ...DESKS,
98
+ { key: "closed", label: "Closed", color: "emerald" },
99
+ ];
100
+ const stageOf = (st: Stage) => STAGES.find((x) => x.key === st) ?? STAGES[0];
101
+
102
+ // The handoff is MANAGED as tasks: each desk's checklist INFORMS its handoff
103
+ // CTA (open tasks warn and carry over; the CTA never blocks) — actionable,
104
+ // unlike a decorative progress strip.
105
+ interface StageTask {
106
+ id: string;
107
+ label: string;
108
+ stage: Desk;
109
+ done: boolean;
110
+ assignee: string | null;
111
+ }
112
+ // Common-but-OPTIONAL tasks per desk — in a real app, the record type's
113
+ // configured checklist. A NEW record starts with an EMPTY list (tasks truly
114
+ // vary); these commons wait as dismissible suggestion CHIPS under each desk
115
+ // (tap to materialize, ✕ to dismiss) — pills, never rows, so a
116
+ // suggestion can't be mistaken for a task. MANDATORY tasks are a different
117
+ // thing entirely: the app seeds those via a workflow on create — no human
118
+ // types them.
119
+ const SUGGESTED_TASKS: Record<Desk, string[]> = {
120
+ sales: ["Confirm pricing with the customer", "Attach the signed quote", "Verify the customer's tax ID"],
121
+ operations: ["Book the carrier", "Attach the delivery documents", "Confirm the delivery window"],
122
+ accounting: ["Issue every invoice", "Reconcile the receipts"],
123
+ };
124
+
125
+ // Monotonic id for user-added tasks — a length-derived id would collide after
126
+ // a removal (delete then add) and break keys + patch-by-id.
127
+ let taskSeq = 0;
128
+ const newTaskId = () => `t_${(taskSeq += 1)}`;
129
+
130
+ // The in-flight demo record's existing checklist (a NEW record gets []).
131
+ const TASK_SEEDS: StageTask[] = [
132
+ { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01" },
133
+ { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01" },
134
+ { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null },
135
+ { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02" },
136
+ { id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null },
137
+ { id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03" },
138
+ { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null },
139
+ ];
140
+
141
+ /** The owner tag a section heading wears — WHO works this section. */
142
+ function OwnerTag({ stage }: { stage: Desk }) {
143
+ const meta = stageOf(stage);
144
+ return <Badge variant="dot" label={meta.label} color={meta.color} />;
145
+ }
146
+
147
+ // The assignable roster — an app feeds `useMembers()` here.
148
+ const TEAM: MemberSelectMember[] = [
149
+ { id: "mem_01", name: "Sarah Chen" },
150
+ { id: "mem_02", name: "David Park" },
151
+ { id: "mem_03", name: "Maria Lopez" },
152
+ { id: "mem_04", name: "James Walker" },
153
+ ];
154
+
155
+ // The company registry the Fetch button consults (mocked): tax ID → the
156
+ // company's registered contact + city. Unknown-but-valid IDs still resolve so
157
+ // the fill is always demoable.
158
+ const REGISTRY: Record<string, { contact: string; city: string }> = {
159
+ "0312456780": { contact: "Mara Lindqvist", city: "Gothenburg" },
160
+ "0312998820": { contact: "Diego Alvarez", city: "Rotterdam" },
161
+ "0301557742": { contact: "Priya Nair", city: "Singapore" },
162
+ "0312004455": { contact: "Lena Fischer", city: "Antwerp" },
163
+ };
164
+
165
+ const lookupRegistry = (taxId: string) =>
166
+ new Promise<{ contact: string; city: string }>((resolve) => {
167
+ setTimeout(() => resolve(REGISTRY[taxId] ?? { contact: "Accounts desk", city: "Ho Chi Minh City" }), 700);
168
+ });
169
+
170
+ const TERMS = [
171
+ { value: "receipt", label: "Due on receipt" },
172
+ { value: "15", label: "Net 15" },
173
+ { value: "30", label: "Net 30" },
174
+ { value: "45", label: "Net 45" },
175
+ ];
176
+
177
+ const WAREHOUSES = [
178
+ { value: "north", label: "North DC" },
179
+ { value: "central", label: "Central hub" },
180
+ { value: "port", label: "Port cross-dock" },
181
+ ];
182
+
183
+ const PRIORITY = [
184
+ { value: "standard", label: "Standard" },
185
+ { value: "rush", label: "Rush" },
186
+ ];
187
+
188
+ const PRIORITY_DOT: Record<string, string> = {
189
+ standard: colors.zinc[400],
190
+ rush: colors.amber[500],
191
+ };
192
+
193
+ // ── billing — charges grouped into issuable invoice DOCUMENTS on this record
194
+ type Method = "cash" | "transfer" | "card";
195
+ const METHODS: PickerOption<Method>[] = [
196
+ { value: "cash", label: "Cash" },
197
+ { value: "transfer", label: "Bank transfer" },
198
+ { value: "card", label: "Card" },
199
+ ];
200
+
201
+ interface Charge {
202
+ key: string;
203
+ label: string;
204
+ /** The expected list price — shown as ghost text while the line is unset. */
205
+ standard: number;
206
+ amount: number;
207
+ method: Method | "";
208
+ }
209
+
210
+ interface Invoice {
211
+ key: string;
212
+ title: string;
213
+ charges: Charge[];
214
+ /** Lookup code once issued to the e-invoice provider; "" = not issued. */
215
+ ref: string;
216
+ }
217
+
218
+ const BILLING_INITIAL: Invoice[] = [
219
+ {
220
+ key: "delivery",
221
+ title: "Delivery",
222
+ ref: "",
223
+ charges: [
224
+ { key: "freight", label: "Freight", standard: 1_200_000, amount: 1_200_000, method: "cash" },
225
+ { key: "insurance", label: "Insurance", standard: 250_000, amount: 0, method: "" },
226
+ ],
227
+ },
228
+ {
229
+ key: "handling",
230
+ title: "Handling",
231
+ ref: "",
232
+ charges: [{ key: "handling", label: "Handling fee", standard: 150_000, amount: 150_000, method: "" }],
233
+ },
234
+ {
235
+ key: "storage",
236
+ title: "Storage",
237
+ ref: "",
238
+ charges: [{ key: "storage", label: "Storage fee", standard: 80_000, amount: 80_000, method: "transfer" }],
239
+ },
240
+ ];
241
+
242
+ /** A fresh record has nothing billed — the invoice shapes with zeroed lines. */
243
+ const freshInvoices = (): Invoice[] =>
244
+ BILLING_INITIAL.map((inv) => ({ ...inv, ref: "", charges: inv.charges.map((c) => ({ ...c, amount: 0, method: "" as const })) }));
245
+
246
+ type InvoiceState = "none" | "draft" | "issued";
247
+ const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
248
+ const invoiceStatus = (inv: Invoice): InvoiceState => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
249
+ const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
250
+
251
+ const money = (v: number | null) => (v == null ? "" : formatMoney(v));
252
+
253
+ /** Fake persistence for the inline editors — real apps await their mutation. */
254
+ function persist<T>(set: (v: T) => void) {
255
+ return (v: T) =>
256
+ new Promise<void>((resolve) => {
257
+ setTimeout(() => {
258
+ set(v);
259
+ resolve();
260
+ }, 350);
261
+ });
262
+ }
263
+
264
+ /** One invoice document — a hairline-set BAND: a title line, its charge lines
265
+ * as inline rows (amount chip with the list price as ghost text + a one-tap
266
+ * "Standard …" suggestion pill while unset; the payment method turns required
267
+ * the moment the line carries an amount), then the ACTION ROW at the bottom
268
+ * right: the hint naming exactly what's missing → the status dot → Issue.
269
+ * No per-band total — the collect band below owns the number. An ISSUED
270
+ * invoice keeps its lines editable — fees change — and offers Re-issue (a new
271
+ * lookup code replaces the old, confirmed in the same Dialog). */
272
+ function InvoiceBand({
273
+ inv, gateReason, onAmount, onMethod, onIssue,
274
+ }: {
275
+ inv: Invoice;
276
+ /** The customer-level block ("Attach a customer…" / "Add the tax ID…"),
277
+ * resolved by the parent — "" when the customer side is ready. */
278
+ gateReason: string;
279
+ onAmount: (chKey: string, v: number) => Promise<void>;
280
+ onMethod: (chKey: string, m: Method) => Promise<void>;
281
+ onIssue: (inv: Invoice) => void;
282
+ }) {
283
+ const total = invoiceTotal(inv);
284
+ const state = invoiceStatus(inv);
285
+ const missing = missingMethods(inv);
286
+ const ready = total > 0 && missing.length === 0 && gateReason === "";
287
+ // What stands between this invoice and Issue — band-local blockers first
288
+ // (fixable right here), then the customer-level gate.
289
+ const blockReason =
290
+ total === 0
291
+ ? "Enter the fees to issue."
292
+ : missing.length > 0
293
+ ? "Choose a payment method for every charged line."
294
+ : gateReason;
295
+ return (
296
+ <>
297
+ <Divider />
298
+ <View style={{ gap: 8, paddingVertical: 2 }}>
299
+ <Text weight="semibold">{inv.title}</Text>
300
+ {/* two editors per cell → stack earlier than a single-editor table */}
301
+ <DetailTable labelWidth={150} minValueWidth={320}>
302
+ {inv.charges.map((c) => (
303
+ <DetailRow key={c.key} label={c.label}>
304
+ <View style={{ gap: 4 }}>
305
+ {/* amount + method WRAP on narrow — a fixed 160 method next to a
306
+ flex amount crushes the amount chip to nothing on phones */}
307
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
308
+ <View style={{ flexGrow: 1, flexBasis: 150 }}>
309
+ <InlineNumberInput
310
+ value={c.amount || null}
311
+ onSave={(v) => onAmount(c.key, v ?? 0)}
312
+ min={0}
313
+ format={money}
314
+ placeholder={c.standard > 0 ? money(c.standard) : "—"}
315
+ accessibilityLabel={`${c.label} amount`}
316
+ />
317
+ </View>
318
+ <View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
319
+ <InlineSelect
320
+ value={c.method === "" ? null : c.method}
321
+ onSave={(m) => onMethod(c.key, m)}
322
+ options={METHODS}
323
+ placeholder="How paid…"
324
+ disabled={c.amount <= 0}
325
+ accessibilityLabel={`Payment method · ${c.label}`}
326
+ />
327
+ </View>
328
+ </View>
329
+ {/* the one-tap suggestion: fill the list price while unset */}
330
+ {c.amount <= 0 && c.standard > 0 ? (
331
+ <View style={{ flexDirection: "row" }}>
332
+ <Button title={`Standard ${money(c.standard)}`} color="secondary" onPress={() => onAmount(c.key, c.standard)} />
333
+ </View>
334
+ ) : null}
335
+ </View>
336
+ </DetailRow>
337
+ ))}
338
+ </DetailTable>
339
+ {/* the band's action row — bottom right: why-not → status → the action */}
340
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 6 }}>
341
+ {state === "issued" ? (
342
+ <>
343
+ <Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 180 }}>
344
+ {ready ? "" : blockReason}
345
+ </Text>
346
+ <Badge variant="dot" label="Issued" color="emerald" />
347
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
348
+ <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
349
+ </>
350
+ ) : (
351
+ <>
352
+ <Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 180 }}>
353
+ {blockReason}
354
+ </Text>
355
+ <Badge
356
+ variant="dot"
357
+ label={state === "draft" ? "Draft" : "Nothing to bill"}
358
+ color={state === "draft" ? "amber" : "zinc"}
359
+ />
360
+ <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
361
+ </>
362
+ )}
363
+ </View>
364
+ </View>
365
+ </>
366
+ );
367
+ }
368
+
369
+ export function TplRecord() {
370
+ const id = useRef(1);
371
+ const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
372
+
373
+ // ── the record — seeded mid-flight so every state is visible
374
+ const [code, setCode] = useState("RC-2026-0418");
375
+ const [stage, setStage] = useState<Stage>("sales");
376
+ // The customer book is STATE: the attached customer's tax ID edits inline in
377
+ // the Customer section (that's what un-gates invoicing).
378
+ const [customers, setCustomers] = useState<Customer[]>(KNOWN_CUSTOMERS);
379
+ const [customerId, setCustomerId] = useState<string | null>("cus_02");
380
+ const customer = customers.find((c) => c.id === customerId) ?? null;
381
+
382
+ // ── facts + details (all inline-persisted)
383
+ const [orderDate, setOrderDate] = useState("2026-06-15");
384
+ const [deliverBy, setDeliverBy] = useState("2026-06-22");
385
+ const [terms, setTerms] = useState("30");
386
+ const [priority, setPriority] = useState("standard");
387
+ const [warehouse, setWarehouse] = useState("central");
388
+ const [reference, setReference] = useState("");
389
+ const [weight, setWeight] = useState<number | null>(1250);
390
+ const [salesOwner, setSalesOwner] = useState<string | null>("mem_01");
391
+
392
+ // ── customer section — TWO states: attached (read-only-first card, Remove
393
+ // detaches) or empty (the find-or-create search). The "create" branch opens
394
+ // a focused Dialog; non-null draft = the dialog is open.
395
+ const [custDraft, setCustDraft] = useState<{ name: string; taxId: string; contact: string; city: string } | null>(null);
396
+ // The attached card RESTS read-only; Edit switches its fields to inline chips.
397
+ const [editingCustomer, setEditingCustomer] = useState(false);
398
+ // The Fetch button's in-flight state (the registry lookup).
399
+ const [fetching, setFetching] = useState(false);
400
+
401
+ // ── billing — the bill-to tax ID comes off the CUSTOMER
402
+ const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
403
+ const [deposit, setDeposit] = useState(0);
404
+ const [confirmIssue, setConfirmIssue] = useState<Invoice | null>(null);
405
+ const seq = useRef(414);
406
+ const fileSeq = useRef(0);
407
+ const taxId = customer?.taxId ?? "";
408
+ const taxIdValid = TAX_ID_RE.test(taxId);
409
+
410
+ // ── files (ids from a monotonic counter — name+index would collide after a
411
+ // removal and break React keys + the gallery index)
412
+ const [files, setFiles] = useState<DisplayFile[]>([]);
413
+ const [activeFile, setActiveFile] = useState<number | null>(null);
414
+
415
+ // ── the handoff tasks — the current desk's checklist gates its handoff
416
+ const [tasks, setTasks] = useState<StageTask[]>(TASK_SEEDS);
417
+ const [newTask, setNewTask] = useState("");
418
+ const toggleTask = (tid: string, done: boolean) =>
419
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, done } : t)));
420
+ const renameTask = (tid: string, label: string) =>
421
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, label } : t)));
422
+ // The capture desk: adds land on the current desk; a CLOSED record's adds
423
+ // land on the last desk (the stage never gates task editing).
424
+ const captureDesk: Desk = stage === "closed" ? "accounting" : stage;
425
+ const addTask = () => {
426
+ const label = newTask.trim();
427
+ if (!label) return;
428
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null }]);
429
+ setNewTask("");
430
+ };
431
+ const addSuggested = (desk: Desk, label: string) =>
432
+ setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null }]);
433
+ const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
434
+ const moveTask = (tid: string, desk: Desk) =>
435
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
436
+ const nextDeskOf = (desk: Desk): Desk | null =>
437
+ desk === "sales" ? "operations" : desk === "operations" ? "accounting" : null;
438
+ const [dismissed, setDismissed] = useState<string[]>([]);
439
+ const assignTask = (tid: string, member: string | null) =>
440
+ setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
441
+ // The Task-list grammar: a clearable Group-by + filter chips derive the
442
+ // sections; empty groups drop (except desks — the journey stays visible).
443
+ const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
444
+ const [assigneeFilter, setAssigneeFilter] = useState<string[]>([]);
445
+ const [statusFilter, setStatusFilter] = useState<"open" | "done" | null>(null);
446
+ const taskPool = tasks.filter((t) => {
447
+ if (assigneeFilter.length > 0 && !assigneeFilter.includes(t.assignee ?? "none")) return false;
448
+ if (statusFilter === "open" && t.done) return false;
449
+ if (statusFilter === "done" && !t.done) return false;
450
+ return true;
451
+ });
452
+ const suggesting = assigneeFilter.length === 0 && statusFilter === null;
453
+ const taskSections: { key: string; head: React.ReactNode; items: StageTask[]; desk: Desk | null; ghosts: string[] }[] =
454
+ taskGroup === "desk"
455
+ ? DESKS.map((st) => ({
456
+ key: st.key,
457
+ head: <Badge variant="dot" label={st.label} color={st.color} />,
458
+ items: taskPool.filter((t) => t.stage === st.key),
459
+ desk: st.key,
460
+ ghosts: suggesting ? SUGGESTED_TASKS[st.key].filter((l) => !tasks.some((t) => t.label === l) && !dismissed.includes(l)) : [],
461
+ }))
462
+ : taskGroup === "assignee"
463
+ ? [...TEAM.map((m) => ({ key: m.id, head: <MemberChip name={m.name ?? m.id} />, items: taskPool.filter((t) => t.assignee === m.id), desk: null, ghosts: [] })),
464
+ { key: "none", head: <Text size="xs" weight="semibold" color="muted">Unassigned</Text>, items: taskPool.filter((t) => t.assignee == null), desk: null, ghosts: [] },
465
+ ].filter((g) => g.items.length > 0)
466
+ : taskGroup === "status"
467
+ ? [
468
+ { key: "open", head: <Text size="xs" weight="semibold" color="muted">Open</Text>, items: taskPool.filter((t) => !t.done), desk: null, ghosts: [] },
469
+ { key: "done", head: <Text size="xs" weight="semibold" color="muted">Done</Text>, items: taskPool.filter((t) => t.done), desk: null, ghosts: [] },
470
+ ].filter((g) => g.items.length > 0)
471
+ : [{ key: "all", head: null, items: taskPool, desk: null, ghosts: [] }];
472
+ const remaining = tasks.filter((t) => t.stage === stage && !t.done).length;
473
+
474
+ // ── preferences
475
+ const [notifyCustomer, setNotifyCustomer] = useState(true);
476
+ const [partialDelivery, setPartialDelivery] = useState(false);
477
+
478
+ const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
479
+ value: c.id,
480
+ label: c.name,
481
+ data: c,
482
+ }));
483
+
484
+ const patchCustomer = (cid: string, patch: Partial<Customer>) =>
485
+ setCustomers((prev) => prev.map((c) => (c.id === cid ? { ...c, ...patch } : c)));
486
+
487
+ // Find-or-create resolution: an existing pick attaches; the custom row opens
488
+ // the create Dialog, prefilled with the query.
489
+ const onPickCustomer = (opt: PickerOption<string, Customer>) => {
490
+ if (customers.some((c) => c.id === opt.value)) {
491
+ setCustomerId(opt.value);
492
+ setEditingCustomer(false);
493
+ return;
494
+ }
495
+ setCustDraft({ name: opt.value, taxId: "", contact: "", city: "" });
496
+ };
497
+
498
+ const taxIdError =
499
+ custDraft && custDraft.taxId && !TAX_ID_RE.test(custDraft.taxId.trim()) ? "Tax ID is 10 or 13 digits" : undefined;
500
+
501
+ const createAndAttach = () => {
502
+ if (!custDraft) return;
503
+ const c: Customer = {
504
+ id: nextId("cus"),
505
+ name: custDraft.name.trim(),
506
+ code: `KH-${String(300 + id.current).padStart(4, "0")}`,
507
+ taxId: custDraft.taxId.trim(),
508
+ contact: custDraft.contact.trim(),
509
+ city: custDraft.city.trim(),
510
+ };
511
+ setCustomers((prev) => [...prev, c]);
512
+ setCustomerId(c.id);
513
+ setCustDraft(null);
514
+ setEditingCustomer(false);
515
+ };
516
+
517
+ // The registry lookup — fills Contact + City off the customer's tax ID.
518
+ const fetchDetails = async (c: Customer) => {
519
+ setFetching(true);
520
+ const details = await lookupRegistry(c.taxId.trim());
521
+ patchCustomer(c.id, details);
522
+ setFetching(false);
523
+ };
524
+
525
+ // The same lookup for the create Dialog — fills the draft's fields.
526
+ const fetchIntoDraft = async () => {
527
+ if (!custDraft) return;
528
+ setFetching(true);
529
+ const details = await lookupRegistry(custDraft.taxId.trim());
530
+ setCustDraft((d) => (d ? { ...d, ...details } : d));
531
+ setFetching(false);
532
+ };
533
+
534
+ // "New order" is ONE CLICK — a fresh Draft, refined entirely on this surface.
535
+ const newOrder = () => {
536
+ setCode(`RC-2026-0${420 + (id.current += 1)}`);
537
+ setStage("sales");
538
+ setCustomerId(null);
539
+ setCustDraft(null);
540
+ setEditingCustomer(false);
541
+ setOrderDate("2026-07-04");
542
+ setDeliverBy("");
543
+ setTerms("30");
544
+ setPriority("standard");
545
+ setWarehouse("central");
546
+ setReference("");
547
+ setWeight(null);
548
+ setSalesOwner(null);
549
+ setInvoices(freshInvoices());
550
+ setDeposit(0);
551
+ setFiles([]);
552
+ setTasks([]);
553
+ setDismissed([]);
554
+ setNewTask("");
555
+ setNotifyCustomer(true);
556
+ setPartialDelivery(false);
557
+ };
558
+
559
+ const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
560
+ setInvoices((prev) =>
561
+ prev.map((inv) =>
562
+ inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
563
+ ),
564
+ );
565
+
566
+ /** Inline-editor persistence for one charge cell. */
567
+ const saveCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
568
+ new Promise<void>((resolve) => {
569
+ setTimeout(() => {
570
+ patchCharge(invKey, chKey, patch);
571
+ resolve();
572
+ }, 350);
573
+ });
574
+
575
+ const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
576
+ const allMissing = invoices.flatMap(missingMethods);
577
+ const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
578
+
579
+ const issue = (inv: Invoice) => {
580
+ seq.current += 1;
581
+ const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
582
+ setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
583
+ setConfirmIssue(null);
584
+ };
585
+
586
+ const printReceipt = () => {
587
+ if (allMissing.length > 0) {
588
+ Alert.alert(
589
+ "Missing payment method",
590
+ `Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
591
+ [{ text: "OK" }],
592
+ );
593
+ return;
594
+ }
595
+ Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
596
+ };
597
+
598
+ // Open tasks INFORM the handoff, they never block it — the count warns and
599
+ // carries over to the next desk (or stays open on the closed record).
600
+ const taskHint =
601
+ stage === "accounting"
602
+ ? `${remaining} open ${remaining === 1 ? "task stays" : "tasks stay"} open on the closed record.`
603
+ : `${remaining} open ${remaining === 1 ? "task carries" : "tasks carry"} over to the next desk.`;
604
+ const gate =
605
+ stage === "sales"
606
+ ? { cta: "Hand off to Operations", next: "operations" as Stage }
607
+ : stage === "operations"
608
+ ? { cta: "Hand off to Accounting", next: "accounting" as Stage }
609
+ : stage === "accounting"
610
+ ? { cta: "Close record", next: "closed" as Stage }
611
+ : null;
612
+ const prevStage: Stage | null = stage === "operations" ? "sales" : stage === "accounting" ? "operations" : null;
613
+
614
+ // ── the outline rail — jump to a section instead of scrolling the long page
615
+ const SECTIONS = [
616
+ { key: "details", label: "Details", icon: "file-text" },
617
+ { key: "customer", label: "Customer", icon: "building-2" },
618
+ { key: "billing", label: "Billing", icon: "credit-card" },
619
+ { key: "files", label: "Files", icon: "folder-closed" },
620
+ { key: "tasks", label: "Tasks", icon: "list-checks" },
621
+ { key: "preferences", label: "Preferences", icon: "sliders-horizontal" },
622
+ ] as const;
623
+ const nav = useSectionNav(["details", "customer", "billing", "files", "tasks", "preferences"] as const);
624
+ // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
625
+ // it opens a full-page section picker (Escape / the close control dismiss).
626
+ const [sectionsOpen, setSectionsOpen] = useState(false);
627
+ const [pageWidth, setPageWidth] = useState<number | null>(null);
628
+ const wide = pageWidth != null && pageWidth >= 760;
629
+
630
+ return (
631
+ // Measure-then-reveal: hidden until the width is known, so the rail/bar
632
+ // choice never visibly reshuffles the page on first paint.
633
+ <View style={{ flex: 1, flexDirection: wide ? "row" : "column", backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
634
+ {pageWidth == null ? null : wide ? (
635
+ <View style={{ width: 208, borderRightWidth: 1, borderRightColor: colors.zinc[200], padding: 8 }}>
636
+ <View style={{ gap: 2 }}>
637
+ {SECTIONS.map((sec) => (
638
+ <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
639
+ ))}
640
+ </View>
641
+ </View>
642
+ ) : (
643
+ // Narrow: a pinned bar names the CURRENT section (the spy keeps it
644
+ // honest while scrolling); tapping opens the full-page section picker —
645
+ // no tab strip the thumb has to scroll.
646
+ <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: 12, paddingVertical: 6, flexDirection: "row" }}>
647
+ <Button
648
+ title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
649
+ color="secondary"
650
+ icon="list-collapse"
651
+ onPress={() => setSectionsOpen(true)}
652
+ />
653
+ </View>
654
+ )}
655
+ <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
656
+ <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 32 }}>
657
+ {/* app bar — CREATE is one click; the surface is where everything is refined */}
658
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 10, rowGap: 8 }}>
659
+ <Text size="sm" color="muted">Records <Text size="sm" color="muted">/</Text> <Text size="sm" weight="medium" color="default" tabular>{code}</Text></Text>
660
+ <View style={{ flex: 1 }} />
661
+ <Button title="New record" color="secondary" icon="plus" onPress={newOrder} />
662
+ </View>
663
+
664
+ <RecordSummary
665
+ title={code}
666
+ subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(" · ") : "No customer"}
667
+ status={<Badge label={stageOf(stage).label} color={stageOf(stage).color} />}
668
+ metric={{ label: "To collect", value: formatMoney(grandTotal), tone: stage === "closed" ? "success" : "default" }}
669
+ />
670
+
671
+ {/* the header's key facts — a DetailTable with the SAME columns as the
672
+ Details table below (trailingWidth reserved even though no fact has
673
+ a trailing), so the two parts sit on one page-wide grid */}
674
+ <DetailTable labelWidth={150} trailingWidth={88}>
675
+ <DetailRow label="Sales owner">
676
+ <InlineMemberSelect
677
+ members={TEAM}
678
+ value={salesOwner}
679
+ onSave={persist(setSalesOwner)}
680
+ placeholder="Assign…"
681
+ accessibilityLabel="Sales owner"
682
+ />
683
+ </DetailRow>
684
+ <DetailRow label="Opened">
685
+ <InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
686
+ </DetailRow>
687
+ <DetailRow label="Due">
688
+ <InlineDatePicker value={deliverBy} onSave={persist(setDeliverBy)} locale="en-US" accessibilityLabel="Due date" placeholder="Set a due date…" />
689
+ </DetailRow>
690
+ <DetailRow label="Payment terms">
691
+ <InlineSelect value={terms} options={TERMS} onSave={persist(setTerms)} accessibilityLabel="Payment terms" />
692
+ </DetailRow>
693
+ </DetailTable>
694
+
695
+ <Divider />
696
+
697
+ {/* ONE DetailTable = the record's field grid: label · editor · trailing
698
+ columns set once on the parent, every row aligned like a table —
699
+ editors share one width even when only some rows carry a trailing
700
+ action / badge; read-only values are InlineStatic in the same
701
+ column, aligned to the pixel */}
702
+ <View onLayout={nav.register("details")}>
703
+ <Section>
704
+ <SectionHeading>
705
+ <SectionHeadingTitle description="Every value edits in place — click a chip, change it, it saves on its own.">Details</SectionHeadingTitle>
706
+ <OwnerTag stage="sales" />
707
+ </SectionHeading>
708
+ <DetailTable labelWidth={150} trailingWidth={88}>
709
+ <DetailRow label="Reference" trailing={<Button title="Copy" color="secondary" onPress={() => {}} />}>
710
+ <InlineTextInput value={reference} onSave={persist(setReference)} placeholder="Add buyer's reference…" accessibilityLabel="Reference" />
711
+ </DetailRow>
712
+ <DetailRow label="Priority">
713
+ <InlineSelect
714
+ value={priority}
715
+ onSave={persist(setPriority)}
716
+ options={PRIORITY}
717
+ accessibilityLabel="Priority"
718
+ renderOptionContent={(o) => (
719
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
720
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PRIORITY_DOT[o.value] }} />
721
+ <Text size="sm">{o.label}</Text>
722
+ </View>
723
+ )}
724
+ />
725
+ </DetailRow>
726
+ <DetailRow label="Location">
727
+ <InlineSelect value={warehouse} onSave={persist(setWarehouse)} options={WAREHOUSES} accessibilityLabel="Location" />
728
+ </DetailRow>
729
+ <DetailRow label="Total weight">
730
+ <InlineNumberInput
731
+ value={weight}
732
+ onSave={persist(setWeight)}
733
+ min={0}
734
+ format={(v) => (v == null ? "" : `${v.toLocaleString("en-US")} kg`)}
735
+ placeholder="Add the weight…"
736
+ accessibilityLabel="Total weight"
737
+ />
738
+ </DetailRow>
739
+ <DetailRow label="Record ID" trailing={<Badge label="System" color="zinc" />}>
740
+ <InlineStatic value={code} tabular />
741
+ </DetailRow>
742
+ </DetailTable>
743
+ </Section>
744
+ </View>
745
+
746
+ <Divider />
747
+
748
+ {/* CUSTOMER — TWO states, no swap mode. Attached: the read-only-first
749
+ card (identity + editable fields — Tax ID with the registry Fetch
750
+ that fills Contact + City, which are inline-editable too); Remove
751
+ detaches the LINK (the customer stays in the book) and drops you to
752
+ the empty state. Empty: the find-or-create search; the custom row
753
+ opens the create Dialog. */}
754
+ <View onLayout={nav.register("customer")}>
755
+ <Section>
756
+ <SectionHeading>
757
+ <SectionHeadingTitle description="The bill-to. Its tax ID gates invoicing below — add it here.">Customer</SectionHeadingTitle>
758
+ <OwnerTag stage="sales" />
759
+ {customer && !editingCustomer ? (
760
+ <Button title="Edit" color="secondary" icon="pencil" onPress={() => setEditingCustomer(true)} />
761
+ ) : null}
762
+ </SectionHeading>
763
+ {customer ? (
764
+ <View style={{ gap: 10 }}>
765
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
766
+ <Icon name="building-2" size={16} color={colors.zinc[500]} />
767
+ <Text weight="medium">{customer.name}</Text>
768
+ <Text size="xs" color="muted" tabular>{customer.code}</Text>
769
+ </View>
770
+ {editingCustomer ? (
771
+ <DetailTable labelWidth={150} trailingWidth={88}>
772
+ <DetailRow
773
+ label="Tax ID"
774
+ trailing={
775
+ <Button
776
+ title="Fetch"
777
+ color="secondary"
778
+ loading={fetching}
779
+ disabled={!TAX_ID_RE.test(customer.taxId.trim())}
780
+ onPress={() => fetchDetails(customer)}
781
+ />
782
+ }
783
+ >
784
+ <InlineTextInput
785
+ value={customer.taxId}
786
+ onSave={(v: string) => {
787
+ if (v.trim() !== "" && !TAX_ID_RE.test(v.trim())) throw new Error("Enter a 10- or 13-digit tax ID.");
788
+ return persist((next: string) => patchCustomer(customer.id, { taxId: next.trim() }))(v);
789
+ }}
790
+ placeholder="Add tax ID…"
791
+ accessibilityLabel="Tax ID"
792
+ />
793
+ </DetailRow>
794
+ <DetailRow label="Contact">
795
+ <InlineTextInput
796
+ value={customer.contact}
797
+ onSave={persist((v: string) => patchCustomer(customer.id, { contact: v }))}
798
+ placeholder="Add contact…"
799
+ accessibilityLabel="Contact"
800
+ />
801
+ </DetailRow>
802
+ <DetailRow label="City">
803
+ <InlineTextInput
804
+ value={customer.city}
805
+ onSave={persist((v: string) => patchCustomer(customer.id, { city: v }))}
806
+ placeholder="Add city…"
807
+ accessibilityLabel="City"
808
+ />
809
+ </DetailRow>
810
+ </DetailTable>
811
+ ) : (
812
+ // At REST the card is read-only — flat InlineStatic values in
813
+ // the SAME columns, so entering Edit never shifts the grid.
814
+ <DetailTable labelWidth={150} trailingWidth={88}>
815
+ <DetailRow label="Tax ID">
816
+ <InlineStatic value={customer.taxId || "—"} tabular />
817
+ </DetailRow>
818
+ <DetailRow label="Contact">
819
+ <InlineStatic value={customer.contact || "—"} />
820
+ </DetailRow>
821
+ <DetailRow label="City">
822
+ <InlineStatic value={customer.city || "—"} />
823
+ </DetailRow>
824
+ </DetailTable>
825
+ )}
826
+ {/* the card's closing row — Remove (destructive) low-left at
827
+ rest; while editing, Done commits out at the bottom right */}
828
+ {editingCustomer ? (
829
+ <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
830
+ <Button title="Done" color="primary" onPress={() => setEditingCustomer(false)} />
831
+ </View>
832
+ ) : (
833
+ <View style={{ flexDirection: "row" }}>
834
+ <Button title="Remove" color="danger" icon="x" onPress={() => setCustomerId(null)} />
835
+ </View>
836
+ )}
837
+ </View>
838
+ ) : (
839
+ <View style={{ gap: 10 }}>
840
+ <Combobox
841
+ options={customerOptions}
842
+ onValueChange={onPickCustomer}
843
+ getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
844
+ reflectSelection={false}
845
+ allowCustom
846
+ customOptionPlacement="top"
847
+ customOptionLabel={(q) => `Create new customer “${q}”`}
848
+ >
849
+ <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
850
+ <ComboboxContent emptyText="No customer matches — type a name to create one" />
851
+ </Combobox>
852
+ <Text size="xs" color="muted">
853
+ No match? Pick “Create new customer …” to add one without leaving the order.
854
+ </Text>
855
+ </View>
856
+ )}
857
+ </Section>
858
+ </View>
859
+
860
+ <Divider />
861
+
862
+ {/* BILLING — invoice documents on the record, in the inline vocabulary.
863
+ The issue action lives in each band's header; ONE section-level
864
+ callout explains what blocks issuing (no per-band totals, no
865
+ per-band reasons — the collect band owns the number). */}
866
+ <View onLayout={nav.register("billing")}>
867
+ <Section>
868
+ <SectionHeading>
869
+ <SectionHeadingTitle description="Each invoice owns its charge lines — a charge never lives apart from the document it bills on.">Billing</SectionHeadingTitle>
870
+ <OwnerTag stage="accounting" />
871
+ </SectionHeading>
872
+
873
+ {stage === "sales" || stage === "operations" ? (
874
+ <Callout tone="info">
875
+ <CalloutText>Billing opens when the record reaches Accounting — hand off above.</CalloutText>
876
+ </Callout>
877
+ ) : customer === null ? (
878
+ <Callout tone="warning">
879
+ <CalloutText>Attach a customer above to issue invoices.</CalloutText>
880
+ </Callout>
881
+ ) : !taxIdValid ? (
882
+ <Callout tone="warning">
883
+ <CalloutText>{customer.name} has no valid tax ID — add it in the Customer section to issue invoices.</CalloutText>
884
+ </Callout>
885
+ ) : null}
886
+
887
+ <View style={{ gap: 10 }}>
888
+ {invoices.map((inv) => (
889
+ <InvoiceBand
890
+ key={inv.key}
891
+ inv={inv}
892
+ gateReason={
893
+ stage === "sales" || stage === "operations"
894
+ ? "Billing opens at the Accounting stage."
895
+ : customer === null
896
+ ? "Attach a customer to issue."
897
+ : !taxIdValid
898
+ ? "Add the customer's tax ID to issue."
899
+ : ""
900
+ }
901
+ onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
902
+ onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
903
+ onIssue={setConfirmIssue}
904
+ />
905
+ ))}
906
+ </View>
907
+
908
+ {/* collect band */}
909
+ <Divider />
910
+ <View style={{ gap: 10 }}>
911
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
912
+ <Text weight="semibold" style={{ flex: 1 }}>Total to collect</Text>
913
+ <Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
914
+ </View>
915
+ {allMissing.length > 0 ? (
916
+ <Callout tone="warning">
917
+ <CalloutText>
918
+ {allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
919
+ </CalloutText>
920
+ </Callout>
921
+ ) : null}
922
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
923
+ <Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
924
+ <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
925
+ </View>
926
+ </View>
927
+
928
+ {/* deposit band — labelled separate, never part of the total */}
929
+ <Divider />
930
+ <View style={{ gap: 8 }}>
931
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
932
+ <Text weight="semibold">Deposit</Text>
933
+ <Badge label="Collected separately" color="zinc" />
934
+ </View>
935
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
936
+ <Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
937
+ <View style={{ flexGrow: 1, flexBasis: 160 }}>
938
+ <InlineNumberInput
939
+ value={deposit || null}
940
+ onSave={persist((v: number | null) => setDeposit(v ?? 0))}
941
+ min={0}
942
+ format={money}
943
+ placeholder="—"
944
+ accessibilityLabel="Deposit amount"
945
+ />
946
+ </View>
947
+ <Button
948
+ title="Deposit receipt"
949
+ color="secondary"
950
+ disabled={deposit <= 0}
951
+ onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
952
+ />
953
+ </View>
954
+ </View>
955
+ </Section>
956
+ </View>
957
+
958
+ <Divider />
959
+
960
+ {/* FILES — the record's document CRUD: drop to add, click a thumbnail
961
+ to preview in the gallery, ✕ to delete. */}
962
+ <View onLayout={nav.register("files")}>
963
+ <Section>
964
+ <SectionHeading>
965
+ <SectionHeadingTitle description="The signed quote, the PO, photos — drop to add, click to preview.">Files</SectionHeadingTitle>
966
+ <OwnerTag stage="operations" />
967
+ {files.length > 0 ? <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta> : null}
968
+ </SectionHeading>
969
+ {files.length > 0 ? (
970
+ <FileThumbnailGrid
971
+ files={files}
972
+ itemSize={84}
973
+ onFilePress={(f) => setActiveFile(files.findIndex((x) => x.id === f.id))}
974
+ onRemove={(fid) => setFiles((prev) => prev.filter((x) => x.id !== fid))}
975
+ />
976
+ ) : null}
977
+ <FileDropzone
978
+ height={100}
979
+ label="Drop the signed quote or PO"
980
+ hint="or click to browse · PDF, images"
981
+ dropLabel="Release to attach"
982
+ accept="application/pdf,image/*"
983
+ accessibilityLabel="Attach documents"
984
+ onFiles={(dropped) =>
985
+ setFiles((prev) => [
986
+ ...prev,
987
+ ...dropped.map((f) => ({
988
+ id: `file_${(fileSeq.current += 1)}`,
989
+ filename: f.name,
990
+ mimeType: f.type || "application/octet-stream",
991
+ url: URL.createObjectURL(f),
992
+ })),
993
+ ])
994
+ }
995
+ />
996
+ </Section>
997
+ </View>
998
+
999
+ <Divider />
1000
+
1001
+ {/* TASKS — the handoff checklist, grouped by the desk that owns
1002
+ them; every row edits (the stage gates the handoff and Billing,
1003
+ never task editing). The CURRENT desk's open tasks warn beside
1004
+ the handoff CTA below. */}
1005
+ <View onLayout={nav.register("tasks")}>
1006
+ <Section>
1007
+ <SectionHeading>
1008
+ <SectionHeadingTitle description="Each desk clears its checklist to hand the record off.">Tasks</SectionHeadingTitle>
1009
+ {/* the same compact meter as the register's Tasks column */}
1010
+ {tasks.length > 0 ? (
1011
+ <View style={{ width: 120 }}>
1012
+ <ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
1013
+ </View>
1014
+ ) : null}
1015
+ </SectionHeading>
1016
+ {/* the Task-list toolbar grammar: a clearable Group-by FilterChip +
1017
+ filter chips — not a bespoke pill row */}
1018
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
1019
+ <FilterChip
1020
+ label="Group by"
1021
+ summary={taskGroup === "desk" ? "Desk" : taskGroup === "assignee" ? "Assignee" : taskGroup === "status" ? "Status" : undefined}
1022
+ onClear={() => setTaskGroup(null)}
1023
+ clearLabel="Clear grouping"
1024
+ >
1025
+ {({ close }) => (
1026
+ <OptionList
1027
+ search={{ mode: "none" }}
1028
+ options={[
1029
+ { value: "desk", label: "Desk" },
1030
+ { value: "assignee", label: "Assignee" },
1031
+ { value: "status", label: "Status" },
1032
+ ]}
1033
+ value={taskGroup ?? undefined}
1034
+ onValueChange={(v) => {
1035
+ if (v) setTaskGroup(v);
1036
+ close();
1037
+ }}
1038
+ onRequestClose={close}
1039
+ />
1040
+ )}
1041
+ </FilterChip>
1042
+ <FilterChip
1043
+ label="Assignee"
1044
+ summary={assigneeFilter.length > 0 ? `${assigneeFilter.length}` : undefined}
1045
+ onClear={() => setAssigneeFilter([])}
1046
+ clearLabel="Clear assignee filter"
1047
+ >
1048
+ <OptionList
1049
+ search={{ mode: "none" }}
1050
+ multi
1051
+ options={[...TEAM.map((m) => ({ value: m.id, label: m.name ?? m.id })), { value: "none", label: "Unassigned" }]}
1052
+ value={assigneeFilter}
1053
+ onValueChange={setAssigneeFilter}
1054
+ />
1055
+ </FilterChip>
1056
+ <FilterChip
1057
+ label="Status"
1058
+ summary={statusFilter === "open" ? "Open" : statusFilter === "done" ? "Done" : undefined}
1059
+ onClear={() => setStatusFilter(null)}
1060
+ clearLabel="Clear status filter"
1061
+ >
1062
+ {({ close }) => (
1063
+ <OptionList
1064
+ search={{ mode: "none" }}
1065
+ options={[
1066
+ { value: "open", label: "Open" },
1067
+ { value: "done", label: "Done" },
1068
+ ]}
1069
+ value={statusFilter ?? undefined}
1070
+ onValueChange={(v) => {
1071
+ if (v) setStatusFilter(v);
1072
+ close();
1073
+ }}
1074
+ onRequestClose={close}
1075
+ />
1076
+ )}
1077
+ </FilterChip>
1078
+ </View>
1079
+ {/* the capture row at the TOP — a new task lands on the capture desk
1080
+ (the current one; the last desk once closed) */}
1081
+ <CaptureRow value={newTask} onChangeText={setNewTask} onSubmit={addTask} placeholder={`Add a task for ${stageOf(captureDesk).label}…`} accessibilityLabel="Add a task" />
1082
+ <View style={{ gap: 14 }}>
1083
+ {taskSections.map((g) => {
1084
+ const desk = g.desk;
1085
+ return (
1086
+ <View key={g.key} style={{ gap: 6 }}>
1087
+ {g.head ? (
1088
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1089
+ {g.head}
1090
+ {g.items.length > 0 ? (
1091
+ <Text size="xs" color="muted" tabular>{`${g.items.filter((t) => t.done).length}/${g.items.length}`}</Text>
1092
+ ) : null}
1093
+ </View>
1094
+ ) : null}
1095
+ {/* rows ride the Checklist compound — one geometry, EVERY
1096
+ desk's rows fully editable (the stage gates the handoff
1097
+ and Billing, never task editing — planning ahead on a
1098
+ later desk is normal work) */}
1099
+ <Checklist trailingWidth={156}>
1100
+ {g.items.map((t) => {
1101
+ const next = nextDeskOf(t.stage);
1102
+ const menuItems: ActionMenuItem[] = [
1103
+ ...(next !== null
1104
+ ? [{ key: "chuyen", label: `Move to ${stageOf(next).label}`, icon: "arrow-right" as const, onPress: () => moveTask(t.id, next) }]
1105
+ : []),
1106
+ { key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => removeTask(t.id) },
1107
+ ];
1108
+ return (
1109
+ <ChecklistRow
1110
+ key={t.id}
1111
+ control={
1112
+ <CheckCircle
1113
+ checked={t.done}
1114
+ onChange={(on) => toggleTask(t.id, on)}
1115
+ accessibilityLabel={t.label}
1116
+ />
1117
+ }
1118
+ trailing={
1119
+ <InlineMemberSelect
1120
+ background="transparent"
1121
+ members={TEAM}
1122
+ value={t.assignee}
1123
+ onSave={(m) => assignTask(t.id, m)}
1124
+ placeholder="Assign…"
1125
+ accessibilityLabel={`Assignee · ${t.label}`}
1126
+ />
1127
+ }
1128
+ menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
1129
+ >
1130
+ <InlineTextInput
1131
+ background="transparent"
1132
+ value={t.label}
1133
+ onSave={(v) => renameTask(t.id, v)}
1134
+ struck={t.done}
1135
+ accessibilityLabel="Task title"
1136
+ />
1137
+ </ChecklistRow>
1138
+ );
1139
+ })}
1140
+ {/* SUGGESTIONS are pills, not rows — tap to materialize,
1141
+ ✕ dismisses for this record; never counted */}
1142
+ {desk != null && g.ghosts.length > 0 ? (
1143
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, paddingTop: 2, paddingBottom: 4 }}>
1144
+ {g.ghosts.map((label) => (
1145
+ <SuggestionChip
1146
+ key={label}
1147
+ label={label}
1148
+ onAdd={() => addSuggested(desk, label)}
1149
+ onDismiss={() => setDismissed((prev) => (prev.includes(label) ? prev : [...prev, label]))}
1150
+ />
1151
+ ))}
1152
+ </View>
1153
+ ) : null}
1154
+ </Checklist>
1155
+ </View>
1156
+ );
1157
+ })}
1158
+ </View>
1159
+ </Section>
1160
+ </View>
1161
+
1162
+ <Divider />
1163
+
1164
+ {/* THE HANDOFF GATE — the atomic boundary between departments, not a
1165
+ form and not a message: the record itself changes desks. Each stage
1166
+ names what it owes (the why-hint) before its CTA un-gates; the
1167
+ receiving department finds the record in ITS register. */}
1168
+ {gate ? (
1169
+ <View style={{ gap: 12 }}>
1170
+ {remaining > 0 ? (
1171
+ <Callout tone="info">
1172
+ <CalloutText>{taskHint}</CalloutText>
1173
+ </Callout>
1174
+ ) : null}
1175
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8 }}>
1176
+ {prevStage ? (
1177
+ <Button title={`Back to ${stageOf(prevStage).label}`} color="muted" onPress={() => setStage(prevStage)} />
1178
+ ) : null}
1179
+ <Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 160 }}>
1180
+ {`Handing off moves the record to ${stageOf(gate.next).label} — fields stay editable after.`}
1181
+ </Text>
1182
+ <Button title={gate.cta} color="primary" onPress={() => setStage(gate.next)} />
1183
+ </View>
1184
+ </View>
1185
+ ) : (
1186
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8 }}>
1187
+ <Text size="xs" color="muted" style={{ flexGrow: 1, flexBasis: 160 }}>
1188
+ Closed on 04/07/2026 — every field still edits in place and saves on its own.
1189
+ </Text>
1190
+ <Button title="Reopen at Accounting" color="muted" onPress={() => setStage("accounting")} />
1191
+ </View>
1192
+ )}
1193
+
1194
+ <Divider />
1195
+
1196
+ {/* the settings shape: title + description left, a live control right,
1197
+ applied on change */}
1198
+ <View onLayout={nav.register("preferences")}>
1199
+ <Section>
1200
+ <SectionHeading>
1201
+ <SectionHeadingTitle description="Applied as you change them — no Save.">Preferences</SectionHeadingTitle>
1202
+ </SectionHeading>
1203
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
1204
+ <View style={{ flex: 1, gap: 1 }}>
1205
+ <Text size="sm">Notify the customer</Text>
1206
+ <Text size="xs" color="muted">Email changes to the primary contact</Text>
1207
+ </View>
1208
+ <Switch accessibilityLabel="Notify the customer" value={notifyCustomer} onChange={setNotifyCustomer} />
1209
+ </View>
1210
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
1211
+ <View style={{ flex: 1, gap: 1 }}>
1212
+ <Text size="sm">Allow partial delivery</Text>
1213
+ <Text size="xs" color="muted">Ship available lines before the rest arrive</Text>
1214
+ </View>
1215
+ <Switch accessibilityLabel="Allow partial delivery" value={partialDelivery} onChange={setPartialDelivery} />
1216
+ </View>
1217
+ </Section>
1218
+ </View>
1219
+
1220
+ <Divider />
1221
+
1222
+ <DangerZone
1223
+ title="Delete record"
1224
+ description="Permanently remove this record and its invoices, charges, and history. This can't be undone."
1225
+ >
1226
+ <Button title="Delete record" color="danger" onPress={() => {}} />
1227
+ </DangerZone>
1228
+ </View>
1229
+
1230
+ {/* the narrow-mode section picker — a full-page takeover: pick a section
1231
+ to jump there; Escape or the close control dismisses */}
1232
+ <Modal open={sectionsOpen} onClose={() => setSectionsOpen(false)}>
1233
+ <ModalHeader title="Sections" onClose={() => setSectionsOpen(false)} />
1234
+ <ModalBody>
1235
+ <View style={{ gap: 2 }}>
1236
+ {SECTIONS.map((sec) => (
1237
+ <MenuButton
1238
+ key={sec.key}
1239
+ icon={sec.icon}
1240
+ title={sec.label}
1241
+ selected={nav.activeKey === sec.key}
1242
+ onPress={() => {
1243
+ setSectionsOpen(false);
1244
+ nav.jumpTo(sec.key);
1245
+ }}
1246
+ />
1247
+ ))}
1248
+ </View>
1249
+ </ModalBody>
1250
+ </Modal>
1251
+
1252
+ {/* find-or-create's CREATE branch — a focused Dialog (4 fields is
1253
+ dialog-weight; an inline swap shifts the page). Essentials only:
1254
+ after Create & attach, the card's Fetch button fills the rest. */}
1255
+ <Dialog width={520} open={custDraft !== null} onOpenChange={(o) => { if (!o) setCustDraft(null); }}>
1256
+ <DialogHeader>
1257
+ <DialogHeaderTitle>New customer</DialogHeaderTitle>
1258
+ </DialogHeader>
1259
+ {/* the SAME inline-chip table as the edit card — create and edit are
1260
+ one vocabulary, only the commit differs (Create & attach) */}
1261
+ <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 8 }}>
1262
+ <DetailTable labelWidth={150} trailingWidth={88}>
1263
+ <DetailRow label="Customer name">
1264
+ <InlineTextInput
1265
+ value={custDraft?.name ?? ""}
1266
+ onSave={(v) => {
1267
+ setCustDraft((d) => (d ? { ...d, name: v } : d));
1268
+ return Promise.resolve();
1269
+ }}
1270
+ placeholder="Add legal name…"
1271
+ accessibilityLabel="Customer name"
1272
+ />
1273
+ </DetailRow>
1274
+ <DetailRow
1275
+ label="Tax ID"
1276
+ trailing={
1277
+ <Button
1278
+ title="Fetch"
1279
+ color="secondary"
1280
+ loading={fetching}
1281
+ disabled={!TAX_ID_RE.test((custDraft?.taxId ?? "").trim())}
1282
+ onPress={fetchIntoDraft}
1283
+ />
1284
+ }
1285
+ >
1286
+ <InlineTextInput
1287
+ value={custDraft?.taxId ?? ""}
1288
+ onSave={(v) => {
1289
+ setCustDraft((d) => (d ? { ...d, taxId: v } : d));
1290
+ return Promise.resolve();
1291
+ }}
1292
+ placeholder="Add tax ID…"
1293
+ accessibilityLabel="Tax ID"
1294
+ />
1295
+ </DetailRow>
1296
+ <DetailRow label="Contact">
1297
+ <InlineTextInput
1298
+ value={custDraft?.contact ?? ""}
1299
+ onSave={(v) => {
1300
+ setCustDraft((d) => (d ? { ...d, contact: v } : d));
1301
+ return Promise.resolve();
1302
+ }}
1303
+ placeholder="Add contact…"
1304
+ accessibilityLabel="Contact"
1305
+ />
1306
+ </DetailRow>
1307
+ <DetailRow label="City">
1308
+ <InlineTextInput
1309
+ value={custDraft?.city ?? ""}
1310
+ onSave={(v) => {
1311
+ setCustDraft((d) => (d ? { ...d, city: v } : d));
1312
+ return Promise.resolve();
1313
+ }}
1314
+ placeholder="Add city…"
1315
+ accessibilityLabel="City"
1316
+ />
1317
+ </DetailRow>
1318
+ </DetailTable>
1319
+ {taxIdError ? <Text size="xs" color="danger">{taxIdError}</Text> : null}
1320
+ <Text size="xs" color="muted">Created and attached to this order — Fetch fills contact + city from the tax-ID registry.</Text>
1321
+ </View>
1322
+ <DialogFooter>
1323
+ <Button title="Cancel" color="secondary" onPress={() => setCustDraft(null)} />
1324
+ <Button title="Create & attach" color="primary" disabled={(custDraft?.name.trim() ?? "") === "" || !!taxIdError} onPress={createAndAttach} />
1325
+ </DialogFooter>
1326
+ </Dialog>
1327
+
1328
+ <FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} />
1329
+
1330
+ {/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
1331
+ <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssue(null); }}>
1332
+ <DialogHeader>
1333
+ <DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
1334
+ </DialogHeader>
1335
+ <View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
1336
+ <Callout tone="warning">
1337
+ <CalloutText>
1338
+ {confirmIssue
1339
+ ? confirmIssue.ref
1340
+ ? `Re-issue "${confirmIssue.title}" (${formatMoney(invoiceTotal(confirmIssue))}) with the current charges. A NEW lookup code replaces ${confirmIssue.ref}; this can't be undone.`
1341
+ : `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.`
1342
+ : ""}
1343
+ </CalloutText>
1344
+ </Callout>
1345
+ </View>
1346
+ <DialogFooter>
1347
+ <Button title="Cancel" color="secondary" onPress={() => setConfirmIssue(null)} />
1348
+ <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
1349
+ </DialogFooter>
1350
+ </Dialog>
1351
+ </ScrollView>
1352
+ </View>
1353
+ );
1354
+ }