@lotics/ui 11.8.9 → 12.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.
@@ -1,10 +1,10 @@
1
- import { useRef, useState } from "react";
2
- import { ScrollView, View } from "react-native";
1
+ import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
5
  import { Button } from "@lotics/ui/button";
6
+ import { BackButton } from "@lotics/ui/back_button";
6
7
  import { Divider } from "@lotics/ui/divider";
7
- import { Badge } from "@lotics/ui/badge";
8
8
  import { Link } from "@lotics/ui/link";
9
9
  import { Icon } from "@lotics/ui/icon";
10
10
  import { Alert } from "@lotics/ui/alert";
@@ -12,20 +12,27 @@ import type { PickerOption } from "@lotics/ui/picker";
12
12
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
13
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
14
14
  import { Callout, CalloutText } from "@lotics/ui/callout";
15
- import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
15
+ import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
16
16
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
17
17
  import { MenuButton } from "@lotics/ui/menu_button";
18
18
  import { Modal, ModalBody, ModalHeader } from "@lotics/ui/modal";
19
19
  import { useSectionNav } from "@lotics/ui/use_section_nav";
20
- import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
20
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
21
21
  import { formatMoney } from "@lotics/ui/format_money";
22
22
  import { RecordSummary } from "@lotics/ui/record_summary";
23
+ import { SummaryLine } from "@lotics/ui/summary_line";
24
+ import { Skeleton } from "@lotics/ui/skeleton";
25
+ import { CommentList, type CommentEditFormProps, type ThreadComment, type ThreadFile } from "@lotics/ui/comments_thread";
26
+ import { Composer } from "@lotics/ui/composer";
27
+ import { IconButton } from "@lotics/ui/icon_button";
28
+ import { FileRows } from "@lotics/ui/file_rows";
29
+ import { FileGrid } from "@lotics/ui/file_grid";
23
30
  import { CheckCircle } from "@lotics/ui/check_circle";
24
31
  import { ProgressBar } from "@lotics/ui/progress_bar";
25
32
  import { FilterChip } from "@lotics/ui/filter_chip";
26
33
  import { CaptureRow } from "@lotics/ui/capture_row";
27
34
  import { Checklist, ChecklistRow } from "@lotics/ui/checklist";
28
- import type { ActionMenuItem } from "@lotics/ui/action_menu";
35
+ import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
29
36
  import { SuggestionChip } from "@lotics/ui/suggestion_chip";
30
37
  import { OptionList } from "@lotics/ui/option_list";
31
38
  import { MemberChip } from "@lotics/ui/member_chip";
@@ -35,32 +42,75 @@ import { InlineNumberInput } from "@lotics/ui/inline_number_input";
35
42
  import { InlineSelect } from "@lotics/ui/inline_select";
36
43
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
37
44
  import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
38
- import type { MemberSelectMember } from "@lotics/ui/member_select";
39
- import { Switch } from "@lotics/ui/switch";
40
- import { FileDropzone } from "@lotics/ui/file_dropzone";
41
- import type { DisplayFile } from "@lotics/ui/file_thumbnail";
42
- import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
45
+ import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select";
46
+ import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
47
+ import { EmptyState } from "@lotics/ui/empty_state";
48
+ import { FormField } from "@lotics/ui/form_field";
49
+ import { TextInputField } from "@lotics/ui/text_input_field";
50
+ import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
51
+ import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
43
52
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
44
53
  import { DangerZone } from "@lotics/ui/danger_zone";
54
+ import { FileRow } from "@lotics/ui/file_row";
55
+ import { pickFiles } from "@lotics/ui/file_picker";
56
+ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
57
+ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
58
+ import { Finding, FindingComparison } from "@lotics/ui/finding";
59
+ import { FormTextInput } from "@lotics/ui/form_text_input";
60
+ import { CheckboxInput } from "@lotics/ui/checkbox_input";
61
+ import { TextLink } from "@lotics/ui/text_link";
62
+ import { PressableRow } from "@lotics/ui/pressable_row";
63
+ import { useFocusRing } from "@lotics/ui/use_focus_ring";
64
+ import { FOCUS_RING } from "@lotics/ui/control_surface";
65
+ import { RadioPicker } from "@lotics/ui/radio_picker";
66
+ import { SearchInput } from "@lotics/ui/search_input";
67
+ import { useSelection } from "@lotics/ui/use_selection";
68
+ import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
69
+ import { CardSelectItem } from "@lotics/ui/card_select_item";
70
+ import { AgentRun, type AgentRunItem, type AgentRunStep } from "@lotics/ui/agent_run";
71
+ import { type SourceRef } from "@lotics/ui/sources";
72
+ import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
73
+ import { CompletionState } from "@lotics/ui/completion_state";
45
74
 
46
75
  // ─────────────────────────────────────────────────────────────────────────────
47
- // Template · Record — THE record surface, create-then-refine taken all the way:
48
- // "New order" is ONE CLICK (no dialog, no form)it creates a fresh Draft and
49
- // everything is refined here, in place. Top bottom: a `RecordSummary` header
50
- // (identity · status · the to-collect metric) over its key-fact `DetailTable`,
51
- // a Details `DetailTable`, the CUSTOMER section (TWO states: attached = the
52
- // read-only-first card Tax ID / Contact / City edit inline, and the Fetch
53
- // button fills contact + city from the tax-ID registry, un-gating invoicing;
54
- // Remove detaches the link the find-or-create search, whose custom row opens
55
- // the create Dialog), BILLING charges grouped into issuable invoice
56
- // documents, every value in the inline vocabulary (amount chips with the list
57
- // price as ghost text, "How paid…" select chips), the issue action in each
58
- // band's header, ONE section-level callout explaining what blocks issuing, the
59
- // collect total, and a separate refundable deposit and FILES (drop to add,
60
- // gallery preview, delete). The lifecycle is the HANDOFF CHAIN below —
61
- // per-desk task checklists inform each stage's handoff CTA (never blocking);
62
- // there is no separate submit step. Also the settings shape:
63
- // Preferences switch rows + the `DangerZone`.
76
+ // Template · Record — THE record surface: the surface IS the editor, every
77
+ // field refines in place. No breadcrumb, no create CTA back lives in the
78
+ // panel and creation belongs to the REGISTER (the list owns "new").
79
+ //
80
+ // GENERIC BY DESIGN: this template is the BASE every industry adapts. Flavor
81
+ // lives in VALUES, never in STRUCTURE each section below is a reusable
82
+ // PATTERN, and the mock nouns stay at the common-denominator level any goods/
83
+ // services business uses (service level, destination, delivery receipt),
84
+ // never one vertical's jargon. Adapt by swapping the values; keep the
85
+ // patterns.
86
+ //
87
+ // THE SECTION PATTERNS (what each is, when to use it):
88
+ // · General — the CORE FACTS: a headingless key-facts lead + named
89
+ // groups + Classification (the right-input-per-field
90
+ // showcase) + System ids. Always present, first in rail.
91
+ // · Comments — the discussion thread. When people collaborate here.
92
+ // · Tasks — the working checklist. Multi-step records with owners.
93
+ // · Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
94
+ // fork (shared with tpl_documents — change BOTH; the one
95
+ // divergence: generation lives in the output sections
96
+ // here, in the toolbar dialog there).
97
+ // · Customer — the LINKED PARTY: another record referenced, never
98
+ // edited here (the linked-record box + drawer).
99
+ // · Fees — the MONEY LEDGER: cost/charge lines, both directions.
100
+ // · Billing — INVOICING: charges grouped into issuable documents.
101
+ // · Document set — the BATCH OUTPUT desk: the record produces per-party
102
+ // form SETS on demand (readiness, fill panels, the gate).
103
+ // · Delivery receipt— the QUICK-ISSUE form: one document issued from a
104
+ // handful of facts at a known moment (a handover, an
105
+ // inspection, a visit).
106
+ // · Activity — the AUDIT TRAIL. Always.
107
+ // · Handoff — the BOUNDARY: the record crosses to another desk's
108
+ // table ONCE (creates + links the sibling; Recall undoes).
109
+ // · Danger zone — destructive lifecycle. Always last.
110
+ //
111
+ // The lifecycle is the HANDOFF CHAIN — per-desk task checklists inform the
112
+ // handoff CTA (never blocking), and the gate is the page's LAST block before
113
+ // the closing `DangerZone`; there is no separate submit step.
64
114
  // ─────────────────────────────────────────────────────────────────────────────
65
115
 
66
116
  interface Customer {
@@ -89,14 +139,14 @@ const TAX_ID_RE = /^\d{10}(\d{3})?$/;
89
139
  // the next desk (spine + stage = handoff) — open tasks warn, they never block.
90
140
  type Stage = "sales" | "operations" | "accounting" | "closed";
91
141
  type Desk = Exclude<Stage, "closed">;
92
- const DESKS: { key: Desk; label: string; color: "blue" | "amber" | "violet" }[] = [
93
- { key: "sales", label: "Sales", color: "blue" },
94
- { key: "operations", label: "Operations", color: "amber" },
95
- { key: "accounting", label: "Accounting", color: "violet" },
142
+ const DESKS: { key: Desk; label: string }[] = [
143
+ { key: "sales", label: "Sales" },
144
+ { key: "operations", label: "Operations" },
145
+ { key: "accounting", label: "Accounting" },
96
146
  ];
97
- const STAGES: { key: Stage; label: string; color: "blue" | "amber" | "violet" | "emerald" }[] = [
147
+ const STAGES: { key: Stage; label: string }[] = [
98
148
  ...DESKS,
99
- { key: "closed", label: "Closed", color: "emerald" },
149
+ { key: "closed", label: "Closed" },
100
150
  ];
101
151
  const stageOf = (st: Stage) => STAGES.find((x) => x.key === st) ?? STAGES[0];
102
152
 
@@ -139,12 +189,6 @@ const TASK_SEEDS: StageTask[] = [
139
189
  { id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null },
140
190
  ];
141
191
 
142
- /** The owner tag a section heading wears — WHO works this section. */
143
- function OwnerTag({ stage }: { stage: Desk }) {
144
- const meta = stageOf(stage);
145
- return <Badge variant="dot" label={meta.label} color={meta.color} />;
146
- }
147
-
148
192
  // The assignable roster — an app feeds `useMembers()` here.
149
193
  const TEAM: MemberSelectMember[] = [
150
194
  { id: "mem_01", name: "Sarah Chen" },
@@ -153,6 +197,7 @@ const TEAM: MemberSelectMember[] = [
153
197
  { id: "mem_04", name: "James Walker" },
154
198
  ];
155
199
 
200
+
156
201
  // The company registry the Fetch button consults (mocked): tax ID → the
157
202
  // company's registered contact + city. Unknown-but-valid IDs still resolve so
158
203
  // the fill is always demoable.
@@ -240,10 +285,6 @@ const BILLING_INITIAL: Invoice[] = [
240
285
  },
241
286
  ];
242
287
 
243
- /** A fresh record has nothing billed — the invoice shapes with zeroed lines. */
244
- const freshInvoices = (): Invoice[] =>
245
- BILLING_INITIAL.map((inv) => ({ ...inv, ref: "", charges: inv.charges.map((c) => ({ ...c, amount: 0, method: "" as const })) }));
246
-
247
288
  type InvoiceState = "none" | "draft" | "issued";
248
289
  const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
249
290
  const invoiceStatus = (inv: Invoice): InvoiceState => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
@@ -262,21 +303,23 @@ function persist<T>(set: (v: T) => void) {
262
303
  });
263
304
  }
264
305
 
265
- /** One invoice document — a hairline-set BAND: a title line, its charge lines
266
- * as inline rows (amount chip with the list price as ghost text + a one-tap
306
+ /** One invoice document — a named SUBSECTION (the Billing section's groups all
307
+ * ride the one SubsectionStack beat): its heading, then the charge lines as
308
+ * inline rows (amount chip with the list price as ghost text + a one-tap
267
309
  * "Standard …" suggestion pill while unset; the payment method turns required
268
- * the moment the line carries an amount), then the ACTION ROW at the bottom
269
- * right: the hint naming exactly what's missing the status dot Issue.
310
+ * the moment the line carries an amount), then the ACTION alone at the bottom
311
+ * right no prose beside a CTA; a not-ready Issue simply stays disabled
312
+ * (the stage/customer gates read at THEIR sections).
270
313
  * No per-band total — the collect band below owns the number. An ISSUED
271
314
  * invoice keeps its lines editable — fees change — and offers Re-issue (a new
272
315
  * lookup code replaces the old, confirmed in the same Dialog). */
273
316
  function InvoiceBand({
274
- inv, gateReason, onAmount, onMethod, onIssue,
317
+ inv, gated, onAmount, onMethod, onIssue,
275
318
  }: {
276
319
  inv: Invoice;
277
- /** The customer-level block ("Attach a customer…" / "Add the tax ID…"),
278
- * resolved by the parent — "" when the customer side is ready. */
279
- gateReason: string;
320
+ /** The record-level gate (wrong stage / no customer / invalid tax ID),
321
+ * resolved by the parent — issuing stays disabled while true. */
322
+ gated: boolean;
280
323
  onAmount: (chKey: string, v: number) => Promise<void>;
281
324
  onMethod: (chKey: string, m: Method) => Promise<void>;
282
325
  onIssue: (inv: Invoice) => void;
@@ -284,22 +327,14 @@ function InvoiceBand({
284
327
  const total = invoiceTotal(inv);
285
328
  const state = invoiceStatus(inv);
286
329
  const missing = missingMethods(inv);
287
- const ready = total > 0 && missing.length === 0 && gateReason === "";
288
- // What stands between this invoice and Issue — band-local blockers first
289
- // (fixable right here), then the customer-level gate.
290
- const blockReason =
291
- total === 0
292
- ? "Enter the fees to issue."
293
- : missing.length > 0
294
- ? "Choose a payment method for every charged line."
295
- : gateReason;
330
+ const ready = total > 0 && missing.length === 0 && !gated;
296
331
  return (
297
- <>
298
- <Divider />
299
- <View style={{ gap: 8, paddingVertical: 2 }}>
300
- <Text weight="semibold">{inv.title}</Text>
301
- {/* two editors per cell → stack earlier than a single-editor table */}
302
- <DetailTable labelWidth={150} minValueWidth={320}>
332
+ <Subsection>
333
+ <SubsectionHeading>
334
+ <SubsectionHeadingTitle>{inv.title}</SubsectionHeadingTitle>
335
+ </SubsectionHeading>
336
+ {/* two editors per cell → stack earlier than a single-editor table */}
337
+ <DetailTable labelWidth={150} minValueWidth={320}>
303
338
  {inv.charges.map((c) => (
304
339
  <DetailRow key={c.key} label={c.label}>
305
340
  <View style={{ gap: 4 }}>
@@ -336,34 +371,29 @@ function InvoiceBand({
336
371
  </View>
337
372
  </DetailRow>
338
373
  ))}
339
- </DetailTable>
340
- {/* the band's action row — bottom right: why-not status the action */}
341
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 6 }}>
342
- {state === "issued" ? (
343
- <>
344
- <Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 180 }}>
345
- {ready ? "" : blockReason}
346
- </Text>
347
- <Badge variant="dot" label="Issued" color="emerald" />
348
- <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
349
- <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
350
- </>
351
- ) : (
352
- <>
353
- <Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 180 }}>
354
- {blockReason}
355
- </Text>
356
- <Badge
357
- variant="dot"
358
- label={state === "draft" ? "Draft" : "Nothing to bill"}
359
- color={state === "draft" ? "amber" : "zinc"}
360
- />
361
- <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
362
- </>
363
- )}
364
- </View>
374
+ </DetailTable>
375
+ {/* band-LOCAL state, co-located: a charged line without a payment method
376
+ is a real inconsistency its callout lives in the band. An EMPTY
377
+ band stays silent a disabled Issue over no fees explains itself. */}
378
+ {missing.length > 0 ? (
379
+ <Callout tone="warning">
380
+ <CalloutText>Choose a payment method for every charged line.</CalloutText>
381
+ </Callout>
382
+ ) : null}
383
+ {/* the band's action row the CTA alone, bottom right (the issued ref
384
+ link IS the issued state); not-ready = disabled, the WHY reads in the
385
+ callouts (band-local above, record-level at the section top) */}
386
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", columnGap: 8 }}>
387
+ {state === "issued" ? (
388
+ <>
389
+ <Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
390
+ <Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
391
+ </>
392
+ ) : (
393
+ <Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
394
+ )}
365
395
  </View>
366
- </>
396
+ </Subsection>
367
397
  );
368
398
  }
369
399
 
@@ -378,12 +408,401 @@ const GUTTER = RAIL_W + RAIL_GAP;
378
408
  /** The reading column's ceiling. */
379
409
  const CONTENT_MAX = 720;
380
410
 
411
+ // ── the DOCUMENT DESK — the Agents "Document desk" pattern (tpl_documents),
412
+ // carried as the record's documents surface: files feed ONE "Use AI" entry
413
+ // that forks into extract / cross-check / edit-with-AI. The two templates
414
+ // share this desk — change it in BOTH (the one divergence: generation lives
415
+ // in this page's output sections, in tpl_documents' toolbar dialog).
416
+ type ScriptStep = Omit<AgentRunStep, "status">;
417
+ type Task = "extract" | "check";
418
+ type Phase = "fork" | "running" | "review" | "done";
419
+
420
+ interface Doc { id: string; name: string; mimeType: string; kind: string; sizeKB: number; added: string; addedAt: number; url?: string }
421
+
422
+ // Display derives from the canonical numeric — strings would sort "8.4 MB" < "96 KB".
423
+ function fmtSize(kb: number): string {
424
+ if (kb <= 0) return "—";
425
+ return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
426
+ }
427
+ const MOCK_PHOTO_URL =
428
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
429
+ const DOCS: Doc[] = [
430
+ { id: "f1", name: "invoice.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 214, added: "26 Jun", addedAt: 626 },
431
+ { id: "f2", name: "packing-list.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 96, added: "26 Jun", addedAt: 626 },
432
+ { id: "f3", name: "booking-confirmation.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 182, added: "28 Jun", addedAt: 628 },
433
+ { id: "f4", name: "photos.zip", mimeType: "application/zip", kind: "ZIP", sizeKB: 8602, added: "30 Jun", addedAt: 630 },
434
+ // an IMAGE file — the register renders it as a real square thumbnail (the
435
+ // SVG data URI stands in for the stored photo URL a live app serves)
436
+ { id: "f5", name: "delivery-photo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1424, added: "30 Jun", addedAt: 630, url: MOCK_PHOTO_URL },
437
+ ];
438
+
439
+ const EXTRACT_STEPS: ScriptStep[] = [
440
+ { id: "e1", label: "Reading the selected documents", detail: "Page by page, tables included" },
441
+ { id: "e2", label: "get_record", kind: "tool" },
442
+ { id: "e3", label: "Extracting field values", detail: "9 fields · 2 order lines" },
443
+ { id: "e4", label: "Comparing against the record", detail: "3 match · 1 new · 1 change · 1 conflict" },
444
+ ];
445
+ const CHECK_STEPS: ScriptStep[] = [
446
+ { id: "c1", label: "Reading the selected documents", detail: "Quantities, parties, dates and terms" },
447
+ { id: "c2", label: "get_record", kind: "tool" },
448
+ { id: "c3", label: "Cross-checking documents and record", detail: "18 fields compared across the sources" },
449
+ ];
450
+
451
+ // The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
452
+ // CONFLICT — are the SAME `ChangeField` row: an add has no `before`, an update
453
+ // bands it, a removal is the − band alone, and a conflict's outcome is the
454
+ // read-only + band over the candidate rows.
455
+ const CARRIER_REF_PROPOSED = "MAEU129394855";
456
+ const VESSEL_CURRENT = "MSC AURA";
457
+ const VESSEL_PROPOSED = "MAERSK SALINA";
458
+ const CONSIGNEE_CURRENT = "Nordic Furniture AB";
459
+ const NOTIFY_CURRENT = "Euro Textile Trading GmbH, Frankfurt";
460
+ const EXTRACT_REASONING = "The booking confirmation names the substitute vessel for this shipping week; the invoice and the packing list disagree on the consignee.";
461
+ const CONSIGNEE_OPTIONS = [
462
+ { value: "Nordic Furniture AB, Jönköping DC", source: "invoice.pdf", recommended: true },
463
+ { value: "NF Distribution ApS, Kolding", source: "packing-list.pdf" },
464
+ ];
465
+ const EXTRACT_FIELD_IDS = ["carrier_ref", "vessel", "consignee", "gross", "notify"] as const;
466
+
467
+ // Cross-check findings — severity reads through ONE colored dot badge (red /
468
+ // amber / zinc), the rest stays calm text.
469
+ type Severity = "critical" | "warning" | "info";
470
+ interface Check { id: string; severity: Severity; title: string; detail?: string; comparison?: { values: { label: string; value: string }[]; delta?: string }; sources?: string[] }
471
+ const FINDINGS: Check[] = [
472
+ {
473
+ id: "q1", severity: "critical", title: "Quantity disagrees between the invoice and the packing list",
474
+ detail: "Short-shipping against the invoice risks a customs query and a client claim.",
475
+ comparison: { values: [{ label: "invoice.pdf", value: "480 pcs" }, { label: "packing-list.pdf", value: "440 pcs" }], delta: "−40 pcs" },
476
+ sources: ["invoice.pdf", "packing-list.pdf"],
477
+ },
478
+ {
479
+ id: "q2", severity: "warning", title: "Consignee differs between the invoice and the booking",
480
+ detail: "The invoice names the buyer's own warehouse; the booking routes delivery through a distribution partner. One of them files wrong.",
481
+ sources: ["invoice.pdf", "booking-confirmation.pdf"],
482
+ },
483
+ {
484
+ id: "q4", severity: "warning", title: "No certificate of origin among the documents",
485
+ detail: "Destination customs requires a certificate of origin for these goods — request it before the shipping cut-off.",
486
+ },
487
+ {
488
+ id: "q3", severity: "info", title: "Booking cut-off is earlier than the invoiced ship week",
489
+ detail: "Documents close 14 Jul on the booking; the invoice quotes shipment in the week of 18 Jul. No conflict if the cargo is ready.",
490
+ sources: ["booking-confirmation.pdf"],
491
+ },
492
+ ];
493
+
494
+ // A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
495
+ const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
496
+ const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
497
+ /** A comment's ThreadFile (snake_case, the API shape) → the kit's DisplayFile. */
498
+ const commentFileToDisplay = (f: ThreadFile): DisplayFile => ({ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.url ?? "", thumbnailUrl: f.thumbnail_url });
499
+
500
+ // The record's discussion — seeded so the thread, a comment ATTACHMENT, and
501
+ // the author-only edit/delete affordance (the viewer is mem_01) are all
502
+ // visible; the composer appends live. An app wires `useComments()` instead.
503
+ let commentSeq = 0;
504
+ const SEED_COMMENTS: ThreadComment[] = [
505
+ {
506
+ id: "cm_a", member_id: "mem_02",
507
+ content: "Carrier booked for the week of 22/06 — the delivery window still needs the customer's confirmation. Confirmation attached.",
508
+ files: [{ id: "cmf_a", filename: "booking-confirmation.pdf", mime_type: "application/pdf", url: MOCK_PDF_URL }],
509
+ created_at: "2026-06-16T09:24:00Z", updated_at: "2026-06-16T09:24:00Z",
510
+ },
511
+ { id: "cm_b", member_id: "mem_01", content: "Signed quote attached. Invoicing waits on the tax ID — chasing it with Diego today.", created_at: "2026-06-16T13:02:00Z", updated_at: "2026-06-16T13:02:00Z" },
512
+ ];
513
+
514
+ // The sibling-record counter — each handoff CREATES the next desk's record on
515
+ // ITS table (OP-…/AC-…) and links it here for reference.
516
+ let siblingSeq = 90;
517
+
518
+ /** "18 Jul, 4:05 PM" — the trail/audit timestamp format. */
519
+ const stamp = (d: Date): string =>
520
+ `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
521
+
522
+ /** A sibling record created by a handoff — lives on ITS desk's table; here we
523
+ * keep the reference data the cards + drawer show. */
524
+ interface SiblingRecord {
525
+ id: string;
526
+ code: string;
527
+ desk: string;
528
+ assignee: string;
529
+ note: string;
530
+ at: string;
531
+ }
532
+
533
+ // ── the FEES ledger — the DETAILED money view (both directions), distinct
534
+ // from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
535
+ // with its party, due date, proof reference and paid state. The register +
536
+ // right-docked entity drawer (◀ ▶ steps the rows) is the drill-down law:
537
+ // every child row opens to its FULL detail.
538
+ type FeeDirection = "charge" | "cost";
539
+ interface Fee {
540
+ id: string;
541
+ name: string;
542
+ /** charge = billed to the customer · cost = paid out to a vendor. */
543
+ direction: FeeDirection;
544
+ party: string;
545
+ amount: number;
546
+ vat: number | null;
547
+ due: string;
548
+ invoiceNo: string;
549
+ paid: boolean;
550
+ note: string;
551
+ }
552
+ let feeSeq = 4;
553
+ const FEE_SEED: Fee[] = [
554
+ { id: "fee_1", name: "Freight surcharge", direction: "charge", party: "Harbor Freight Lines", amount: 350_000, vat: 8, due: "", invoiceNo: "", paid: true, note: "" },
555
+ { id: "fee_2", name: "Trucking", direction: "cost", party: "Northline Haulage", amount: 450_000, vat: 8, due: "2026-07-02", invoiceNo: "NH-2044", paid: false, note: "Last-mile to the port" },
556
+ { id: "fee_3", name: "Customs advance", direction: "cost", party: "Blue Anchor Brokerage", amount: 275_000, vat: null, due: "", invoiceNo: "BA-118", paid: true, note: "" },
557
+ { id: "fee_4", name: "Storage overrun", direction: "charge", party: "Harbor Freight Lines", amount: 120_000, vat: 8, due: "2026-08-02", invoiceNo: "", paid: false, note: "3 extra days at Central hub" },
558
+ ];
559
+ // `priority` = the mobile contract: Party then Type drop first on a narrow
560
+ // container, Amount + Status survive with the identity; at the floor the rows
561
+ // STACK label-over-value — and the entity drawer always carries the full fee.
562
+ const FEE_COLUMNS: TableColumn[] = [
563
+ { key: "fee", label: "Fee", flex: 1 },
564
+ { key: "type", label: "Type", width: 72, priority: 3 },
565
+ { key: "party", label: "Party", width: 150, priority: 4 },
566
+ { key: "amount", label: "Amount", width: 104, align: "right", priority: 1 },
567
+ { key: "status", label: "Status", width: 96, priority: 2 },
568
+ ];
569
+ const feeOverdue = (f: Fee): boolean => !f.paid && f.due !== "" && new Date(f.due) < new Date();
570
+
571
+
572
+ /** The keyboard DOOR of a pressable linked-record box: an EMPTY absolutely-
573
+ * positioned sibling BENEATH the box's content — a button must not contain
574
+ * the box's interactive descendants (the `PressableRow`/`TableRow` law), so
575
+ * the door carries the tab stop, the accessible name and the focus ring;
576
+ * mouse presses ride the `PressableRow` surface, and the interior verbs
577
+ * lift above the door via `zIndex: 1`. */
578
+ function BoxDoor({ label, onPress }: { label: string; onPress: () => void }) {
579
+ const { focusVisible, focusProps } = useFocusRing();
580
+ return (
581
+ <Pressable
582
+ accessibilityRole="button"
583
+ accessibilityLabel={label}
584
+ onPress={onPress}
585
+ {...focusProps}
586
+ style={[
587
+ { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
588
+ focusVisible ? { boxShadow: FOCUS_RING } : null,
589
+ ]}
590
+ />
591
+ );
592
+ }
593
+
594
+ /** The file-capable comment editor injected via `renderEditForm` — text + the
595
+ * comment's attachments (each removable), committed together (the DEFAULT
596
+ * edit form is text-only; the product injects its own equivalent). */
597
+ function CommentFileEditForm({ comment, onSave, onCancel, submitting, labels }: CommentEditFormProps) {
598
+ const [draft, setDraft] = useState(comment.content);
599
+ const [draftFiles, setDraftFiles] = useState<ThreadFile[]>(comment.files ?? []);
600
+ return (
601
+ <View style={{ gap: 8 }}>
602
+ <TextInputField value={draft} onChangeText={setDraft} placeholder={labels.editPlaceholder} multiline numberOfLines={2} accessibilityLabel={labels.editPlaceholder} />
603
+ {draftFiles.length > 0 ? (
604
+ <FileRows files={draftFiles.map(commentFileToDisplay)} onRemove={(f) => setDraftFiles((prev) => prev.filter((x) => x.id !== f.id))} />
605
+ ) : null}
606
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
607
+ <Button title={labels.cancel} color="secondary" disabled={submitting} onPress={onCancel} />
608
+ {/* the adjacent-input exception: an emptied comment can't save */}
609
+ <Button title={labels.save} color="primary" disabled={submitting || (draft.trim() === "" && draftFiles.length === 0)} loading={submitting} onPress={() => onSave(draft.trim(), draftFiles.length > 0 ? draftFiles : null)} />
610
+ </View>
611
+ </View>
612
+ );
613
+ }
614
+
615
+ // The documents register — columns ONCE (the Table renders the header band
616
+ // and every cell width from these; facts get dedicated scannable columns
617
+ // instead of a crammed meta string). Same grammar as the case register
618
+ // (tpl_item_list): leading checkbox + select-all, trailing ⋯ gutter.
619
+ const DOC_COLUMNS: TableColumn[] = [
620
+ { key: "doc", label: "Document", flex: 1, sortable: true },
621
+ { key: "size", label: "Size", width: 90, align: "right", sortable: true, priority: 3 },
622
+ { key: "added", label: "Added", width: 96, sortable: true, priority: 2 },
623
+ ];
624
+
625
+ // ── the DOCUMENT SET — the record's OUTPUT desk. The forms each party may
626
+ // need, grouped PER PARTY; the common ones ship pre-checked, the long tail
627
+ // folds behind "Show all forms". (The Documents section above is the INTAKE
628
+ // register — received files; this registry is what the record can PRODUCE.)
629
+ // READINESS: a form declares which RECORD FIELDS it reads (`needs`). The
630
+ // fields' HOME stays their DATA section (the colocation law); every
631
+ // not-ready row shows its mark AND the explicit "Add missing fields"
632
+ // trigger, which toggles a TRANSIENT fill editor under the row (draft →
633
+ // "Save fields" commits onto the record and the panel collapses) — repair
634
+ // in place, never scrolling off hunting for the field. The hint is each
635
+ // field's consumer line, shown in the panel too.
636
+ type NeedKey = "delivery_address" | "commodity_code" | "hazard_class";
637
+ const NEEDS: Record<NeedKey, { label: string; hint: string }> = {
638
+ delivery_address: { label: "Delivery address", hint: "The consignee's dock — printed on the delivery note" },
639
+ commodity_code: { label: "Commodity code", hint: "The customs papers read it" },
640
+ hazard_class: { label: "Hazard class", hint: "Printed on the hazardous goods note" },
641
+ };
642
+ interface SetForm { id: string; label: string; slug: string; sizeKB: number; common?: boolean; needs?: NeedKey[] }
643
+ interface FormGroup { id: string; party: (customer: string | null) => string; forms: SetForm[]; visible: number }
644
+ const FORM_GROUPS: FormGroup[] = [
645
+ {
646
+ id: "customer",
647
+ party: (c) => `Forms — ${c ?? "customer"}`,
648
+ // the first `visible` rows show; the rest fold ("Show all forms (3 hidden)")
649
+ visible: 4,
650
+ forms: [
651
+ { id: "f1", label: "Shipping instruction", slug: "shipping-instruction", sizeKB: 84, common: true },
652
+ { id: "f2", label: "Commercial invoice", slug: "commercial-invoice", sizeKB: 92, common: true },
653
+ { id: "f3", label: "Packing list", slug: "packing-list", sizeKB: 58, common: true },
654
+ { id: "f4", label: "Delivery note", slug: "delivery-note", sizeKB: 72, needs: ["delivery_address"] },
655
+ { id: "f5", label: "Certificate of origin", slug: "certificate-of-origin", sizeKB: 61, needs: ["commodity_code"] },
656
+ { id: "f6", label: "Insurance certificate", slug: "insurance-certificate", sizeKB: 66 },
657
+ { id: "f7", label: "Arrival notice", slug: "arrival-notice", sizeKB: 49 },
658
+ ],
659
+ },
660
+ {
661
+ id: "carrier",
662
+ party: () => "Forms — Northline Haulage (carrier)",
663
+ visible: 3,
664
+ forms: [
665
+ { id: "c1", label: "Transport order", slug: "transport-order", sizeKB: 77, common: true },
666
+ { id: "c2", label: "Customs declaration draft", slug: "customs-declaration-draft", sizeKB: 118, needs: ["commodity_code"] },
667
+ // DATA-conditional: this form renders only while the record says the
668
+ // goods ARE hazardous (the dependent-field law — record data may
669
+ // reshape the list; output selection never does)
670
+ { id: "c3", label: "Hazardous goods note", slug: "hazardous-goods-note", sizeKB: 83, needs: ["hazard_class"] },
671
+ ],
672
+ },
673
+ ];
674
+ const ALL_FORMS = FORM_GROUPS.flatMap((g) => g.forms);
675
+
676
+ // ── the classification registries — each field gets the input its SHAPE
677
+ // wants (the right-input-per-field law; the worked rows live in General).
678
+ const SERVICE_LEVELS: PickerOption<string>[] = [
679
+ { value: "standard", label: "Standard" },
680
+ { value: "express", label: "Express" },
681
+ { value: "economy", label: "Economy" },
682
+ { value: "scheduled", label: "Scheduled" },
683
+ ];
684
+ const SERVICE_LEVEL_DESC: Record<string, string> = {
685
+ standard: "Door to door on the regular schedule.",
686
+ express: "Priority handling — the first available departure.",
687
+ economy: "Consolidated with other orders — longer lead time, lower cost.",
688
+ scheduled: "A fixed weekly slot agreed with the customer.",
689
+ };
690
+ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
691
+ { value: "rotterdam", label: "Rotterdam", data: { country: "Netherlands" } },
692
+ { value: "hamburg", label: "Hamburg", data: { country: "Germany" } },
693
+ { value: "singapore", label: "Singapore", data: { country: "Singapore" } },
694
+ { value: "losangeles", label: "Los Angeles", data: { country: "United States" } },
695
+ { value: "haiphong", label: "Haiphong", data: { country: "Vietnam" } },
696
+ { value: "hochiminh", label: "Ho Chi Minh City", data: { country: "Vietnam" } },
697
+ { value: "tokyo", label: "Tokyo", data: { country: "Japan" } },
698
+ { value: "dubai", label: "Dubai", data: { country: "United Arab Emirates" } },
699
+ ];
700
+
381
701
  export function TplRecord() {
382
702
  const id = useRef(1);
383
703
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
384
704
 
705
+ // ── first paint = Skeleton MIRRORING the final layout, never a spinner.
706
+ // The 700ms mock stands in for the record query a real app awaits.
707
+ const [booting, setBooting] = useState(true);
708
+ useEffect(() => {
709
+ const t = setTimeout(() => setBooting(false), 700);
710
+ return () => clearTimeout(t);
711
+ }, []);
712
+
713
+ // ── the discussion thread (CommentList + Composer; author-only edit/delete)
714
+ const [comments, setComments] = useState<ThreadComment[]>(SEED_COMMENTS);
715
+ // Files staged on the NEXT comment — consumer-owned, exactly how the
716
+ // product wires it (the kit composer's `actions` slot opts into attach; the
717
+ // frontend adds an upload queue, here pickFiles + local state stand in).
718
+ const [pendingFiles, setPendingFiles] = useState<ThreadFile[]>([]);
719
+ const attachToComment = () => {
720
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((chosen) => {
721
+ if (chosen.length === 0) return;
722
+ setPendingFiles((prev) => [
723
+ ...prev,
724
+ ...chosen.map((f) => ({ id: `cmf_${(commentSeq += 1)}`, filename: f.name, mime_type: f.type || "application/octet-stream", url: URL.createObjectURL(f) })),
725
+ ]);
726
+ });
727
+ };
728
+
729
+ // ── the audit trail — real actions on this surface append to it. An entry
730
+ // DRILLS DOWN via the Timeline `details` chevron: a field change carries its
731
+ // From → To diff, a document entry the pressable file, an issue its amount.
732
+ const [activity, setActivity] = useState<TimelineItem[]>(() => [
733
+ {
734
+ id: "ac_4", icon: "calendar", iconColor: colors.zinc[500],
735
+ label: "Due date changed — Sarah Chen",
736
+ description: "Pulled in a week after the customer confirmed the window.",
737
+ right: <Text size="xs" color="muted">16 Jun, 10:02 AM</Text>,
738
+ details: (
739
+ <DetailTable labelWidth={80}>
740
+ <DetailRow label="From"><InlineStatic value="06/29/2026" tabular /></DetailRow>
741
+ <DetailRow label="To"><InlineStatic value="06/22/2026" tabular /></DetailRow>
742
+ </DetailTable>
743
+ ),
744
+ },
745
+ {
746
+ id: "ac_3", icon: "file-text", iconColor: colors.zinc[500],
747
+ label: "booking-confirmation.pdf added — David Park",
748
+ right: <Text size="xs" color="muted">16 Jun, 9:20 AM</Text>,
749
+ details: (
750
+ <FileRow
751
+ name="booking-confirmation.pdf"
752
+ mimeType="application/pdf"
753
+ onPress={() => setPreview({ files: [{ id: "ac_f1", filename: "booking-confirmation.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], index: 0 })}
754
+ />
755
+ ),
756
+ },
757
+ { id: "ac_2", icon: "building-2", iconColor: colors.zinc[500], label: "Customer attached — Harbor Freight Lines", right: <Text size="xs" color="muted">15 Jun, 2:31 PM</Text> },
758
+ { id: "ac_1", icon: "plus", iconColor: colors.blue[500], label: "Record created — Sarah Chen", right: <Text size="xs" color="muted">15 Jun, 2:30 PM</Text> },
759
+ ]);
760
+ const logActivity = (
761
+ icon: TimelineItem["icon"],
762
+ label: string,
763
+ extra?: { iconColor?: string; description?: string; details?: ReactNode },
764
+ ) =>
765
+ setActivity((prev) => [
766
+ {
767
+ id: `ac_${prev.length + 1}`,
768
+ icon,
769
+ iconColor: extra?.iconColor ?? colors.zinc[500],
770
+ label,
771
+ description: extra?.description,
772
+ details: extra?.details,
773
+ right: <Text size="xs" color="muted">{stamp(new Date())}</Text>,
774
+ },
775
+ ...prev,
776
+ ]);
777
+
778
+ // ── the fees ledger — register + entity drawer (create-then-refine: Add
779
+ // creates a blank fee and opens it; every field refines in the drawer)
780
+ const [fees, setFees] = useState<Fee[]>(FEE_SEED);
781
+ const [feeView, setFeeView] = useState<{ kind: "edit"; id: string } | null>(null);
782
+ const patchFee = (id: string, p: Partial<Fee>) => setFees((prev) => prev.map((f) => (f.id === id ? { ...f, ...p } : f)));
783
+ const addFee = () => {
784
+ const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
785
+ setFees((prev) => [...prev, f]);
786
+ setFeeView({ kind: "edit", id: f.id });
787
+ logActivity("receipt", "Fee added");
788
+ };
789
+ const openFee = feeView ? fees.find((f) => f.id === feeView.id) ?? null : null;
790
+ const feeIdx = openFee ? fees.findIndex((f) => f.id === openFee.id) : -1;
791
+ const deleteFee = (f: Fee) => {
792
+ Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
793
+ { text: "Cancel", style: "cancel" },
794
+ { text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); logActivity("receipt", `Fee removed — ${f.name || "unnamed"}`, { description: `${formatMoney(f.amount)}${f.party ? ` — ${f.party}` : ""}` }); } },
795
+ ]);
796
+ };
797
+ // The status column speaks in facts: Paid · Overdue · Due <date> · Unpaid.
798
+ const feeStatus = (f: Fee): { text: string; danger: boolean } =>
799
+ f.paid ? { text: "Paid", danger: false }
800
+ : feeOverdue(f) ? { text: "Overdue", danger: true }
801
+ : f.due !== "" ? { text: `Due ${new Date(f.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`, danger: false }
802
+ : { text: "Unpaid", danger: false };
803
+
385
804
  // ── the record — seeded mid-flight so every state is visible
386
- const [code, setCode] = useState("RC-2026-0418");
805
+ const code = "RC-2026-0418";
387
806
  const [stage, setStage] = useState<Stage>("sales");
388
807
  // The customer book is STATE: the attached customer's tax ID edits inline in
389
808
  // the Customer section (that's what un-gates invoicing).
@@ -401,12 +820,10 @@ export function TplRecord() {
401
820
  const [weight, setWeight] = useState<number | null>(1250);
402
821
  const [salesOwner, setSalesOwner] = useState<string | null>("mem_01");
403
822
 
404
- // ── customer section — TWO states: attached (read-only-first card, Remove
823
+ // ── customer section — TWO states: attached (read-only card, Remove
405
824
  // detaches) or empty (the find-or-create search). The "create" branch opens
406
825
  // a focused Dialog; non-null draft = the dialog is open.
407
826
  const [custDraft, setCustDraft] = useState<{ name: string; taxId: string; contact: string; city: string } | null>(null);
408
- // The attached card RESTS read-only; Edit switches its fields to inline chips.
409
- const [editingCustomer, setEditingCustomer] = useState(false);
410
827
  // The Fetch button's in-flight state (the registry lookup).
411
828
  const [fetching, setFetching] = useState(false);
412
829
 
@@ -415,14 +832,285 @@ export function TplRecord() {
415
832
  const [deposit, setDeposit] = useState(0);
416
833
  const [confirmIssue, setConfirmIssue] = useState<Invoice | null>(null);
417
834
  const seq = useRef(414);
418
- const fileSeq = useRef(0);
419
835
  const taxId = customer?.taxId ?? "";
420
836
  const taxIdValid = TAX_ID_RE.test(taxId);
421
837
 
422
- // ── files (ids from a monotonic counter name+index would collide after a
423
- // removal and break React keys + the gallery index)
424
- const [files, setFiles] = useState<DisplayFile[]>([]);
425
- const [activeFile, setActiveFile] = useState<number | null>(null);
838
+ // ── the document desk (carried verbatim from tpl_documentssee the module
839
+ // banner above; the record's files ARE the desk's register)
840
+ const [files, setFiles] = useState<Doc[]>(DOCS);
841
+ const sel = useSelection();
842
+
843
+ // Use AI — one entry off the selection, forking into the two document tasks.
844
+ const [aiOpen, setAiOpen] = useState(false);
845
+ const [picked, setPicked] = useState<Doc[]>([]);
846
+ const [brief, setBrief] = useState("");
847
+ // The fork SELECTS a task; the footer CTA runs it (select → confirm, never
848
+ // act-on-press for an analysis that costs a run).
849
+ const [taskChoice, setTaskChoice] = useState<Task | "edit" | null>(null);
850
+ // True when the dialog opened from a fresh upload — the fork then offers
851
+ // keeping the files without running anything.
852
+ const [uploadFlow, setUploadFlow] = useState(false);
853
+ // Full-page preview over whichever list the user was looking at.
854
+ const [preview, setPreview] = useState<{ files: DisplayFile[]; index: number } | null>(null);
855
+ const openPreview = (docs: Doc[], index: number) => setPreview({ files: docs.map(toDisplay), index });
856
+ const [task, setTask] = useState<Task | null>(null);
857
+ const [phase, setPhase] = useState<Phase>("fork");
858
+ const [revealed, setRevealed] = useState(0);
859
+ // The HOST owns the review: one editable proposal per field (editing IS the
860
+ // review), a tri-state decision per field (dropped rows collapse struck,
861
+ // out of the apply), and the conflict's pick (empty until the user chooses).
862
+ const [carrierRef, setCarrierRef] = useState(CARRIER_REF_PROPOSED);
863
+ const [vessel, setVessel] = useState(VESSEL_PROPOSED);
864
+ const [consignee, setConsignee] = useState<string | null>(null);
865
+ const [grossWeight, setGrossWeight] = useState("1,540");
866
+ // Order lines proposed from the packing list — RECORD ops (ChangeRecord).
867
+ const [lineAdd, setLineAdd] = useState<ChangeStatus>("pending");
868
+ const [lineEdit, setLineEdit] = useState<Record<string, "kept" | "dropped">>({});
869
+ const [newItem, setNewItem] = useState("Corner protectors, foam");
870
+ const [newQty, setNewQty] = useState("400");
871
+ const [editedQty, setEditedQty] = useState("1,450");
872
+ const lineEditRow = (id: string) => ({
873
+ status: lineEdit[id] ?? ("pending" as const),
874
+ onKeep: () => setLineEdit((m) => ({ ...m, [id]: "kept" as const })),
875
+ onDrop: () => setLineEdit((m) => ({ ...m, [id]: "dropped" as const })),
876
+ onUndo: () => setLineEdit((m) => { const n = { ...m }; delete n[id]; return n; }),
877
+ });
878
+ const [consigneePick, setConsigneePick] = useState<number | "custom" | null>(null);
879
+ const [customConsignee, setCustomConsignee] = useState("");
880
+ const [fieldDecisions, setFieldDecisions] = useState<Record<string, "kept" | "dropped">>({});
881
+
882
+ const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
883
+ // The upload path's save: pending picked files land on the record only here.
884
+ const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
885
+ const closeAi = () => {
886
+ setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
887
+ setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); setLineAdd("pending"); setLineEdit({}); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); setFieldDecisions({});
888
+ };
889
+ const startTask = (t: Task) => { setTask(t); setRevealed(0); setPhase("running"); };
890
+ // ⋯ menu per document row. "Edit with AI" is the app→chat handoff for
891
+ // dialogue-shaped work on THIS document — a real app calls the SDK:
892
+ // void askAi({ file_ids: [f.id], record_ids: [orderRecordId], prompt: `Update ${f.name} — ` });
893
+ // and the Lotics messenger opens a fresh chat with the file attached AND
894
+ // previewed beside it; the prompt is prefilled, editable, never auto-sent.
895
+ // Structured judgment that commits back into fields stays on the Use-AI
896
+ // review flow. Rename works for real; Download is host-served in an app
897
+ // (openExternal(file.url)).
898
+ // One sort at a time; the third toggle on a column clears it (register law).
899
+ const [docSort, setDocSort] = useState<SortState | null>(null);
900
+ const [docSearch, setDocSearch] = useState("");
901
+ const docQuery = docSearch.trim().toLowerCase();
902
+ const visibleFiles = sortBy(
903
+ docQuery ? files.filter((f) => f.name.toLowerCase().includes(docQuery)) : files,
904
+ docSort,
905
+ (f, key) => (key === "doc" ? f.name.toLowerCase() : key === "size" ? f.sizeKB : f.addedAt),
906
+ );
907
+
908
+ const [renameTarget, setRenameTarget] = useState<Doc | null>(null);
909
+ const [renameDraft, setRenameDraft] = useState("");
910
+ const openRename = (f: Doc) => { setRenameTarget(f); setRenameDraft(f.name); };
911
+ const saveRename = () => {
912
+ const name = renameDraft.trim();
913
+ if (renameTarget && name) setFiles((fs) => fs.map((x) => (x.id === renameTarget.id ? { ...x, name } : x)));
914
+ setRenameTarget(null);
915
+ };
916
+ const editWithAi = (docs: Doc[]) => {
917
+ const names = docs.map((d) => d.name).join(", ");
918
+ Alert.alert(
919
+ "Edit with AI",
920
+ `Opens the Lotics chat on a fresh thread with ${names} attached${docs.length === 1 ? " and previewed side by side" : ""} — describe the change and the agent edits as new versions.`,
921
+ [{ text: "OK" }],
922
+ );
923
+ };
924
+ const fileMenuFor = (f: Doc): ActionMenuItem[] => [
925
+ { key: "rename", label: "Rename", icon: "pencil", onPress: () => openRename(f) },
926
+ { key: "download", label: "Download", icon: "download", onPress: () => { /* a real app: openExternal(f.url) */ } },
927
+ ];
928
+
929
+ const removeSelected = () => {
930
+ Alert.alert(
931
+ `Remove ${sel.count} ${sel.count === 1 ? "file" : "files"}?`,
932
+ "They come off the order's documents — the originals stay wherever they came from.",
933
+ [
934
+ { text: "Keep files", style: "cancel" },
935
+ { text: "Remove", style: "destructive", onPress: () => { setFiles((fs) => fs.filter((f) => !sel.has(f.id))); sel.clear(); } },
936
+ ],
937
+ );
938
+ };
939
+ const decideField = (id: string, d: "kept" | "dropped" | undefined) =>
940
+ setFieldDecisions((m) => {
941
+ const n = { ...m };
942
+ if (d) n[id] = d;
943
+ else delete n[id];
944
+ return n;
945
+ });
946
+ const fieldRow = (id: string) => ({
947
+ status: fieldDecisions[id] ?? ("pending" as const),
948
+ onKeep: () => decideField(id, "kept"),
949
+ onDrop: () => decideField(id, "dropped"),
950
+ onUndo: () => decideField(id, undefined),
951
+ });
952
+
953
+ // The streaming timer idiom — reveal one script step at a time, then settle.
954
+ const script = task === "check" ? CHECK_STEPS : EXTRACT_STEPS;
955
+ useEffect(() => {
956
+ if (phase !== "running") return;
957
+ if (revealed >= script.length) {
958
+ const t = setTimeout(() => setPhase("review"), 850);
959
+ return () => clearTimeout(t);
960
+ }
961
+ const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 280 : 680);
962
+ return () => clearTimeout(t);
963
+ }, [phase, revealed, script.length]);
964
+
965
+ const runItems: AgentRunItem[] = [
966
+ { type: "text", id: "in", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
967
+ ...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): AgentRunItem => ({
968
+ type: "step", ...s, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
969
+ })),
970
+ ];
971
+ if (revealed >= script.length) {
972
+ runItems.push({ type: "text", id: "out", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
973
+ }
974
+
975
+ // Apply counts what actually commits: the kept fields.
976
+ const applyCount = Object.values(fieldDecisions).filter((d) => d === "kept").length + Object.values(lineEdit).filter((d) => d === "kept").length + (lineAdd === "accepted" ? 1 : 0);
977
+ // Keep-all lives with the HOST (field decisions the registry can't see):
978
+ // keep every still-pending field; the unresolved conflict stays pending.
979
+ const keepAll = () => {
980
+ setLineAdd((st) => (st === "pending" ? "accepted" : st));
981
+ setLineEdit((m) => ({ qty: m.qty ?? "kept", ...m }));
982
+ setFieldDecisions((m) => {
983
+ const n = { ...m };
984
+ for (const f of EXTRACT_FIELD_IDS) {
985
+ if (f === "consignee" && consignee == null) continue;
986
+ if (n[f] == null) n[f] = "kept";
987
+ }
988
+ return n;
989
+ });
990
+ };
991
+
992
+ // ── the classification fields — each one carries the input its shape wants
993
+ const [serviceLevel, setServiceLevel] = useState("standard");
994
+ const [fulfilmentStatus, setFulfilmentStatus] = useState("ready");
995
+ const [insurance, setInsurance] = useState("standard");
996
+ const [insuredValue, setInsuredValue] = useState<number | null>(24_000);
997
+ const [destination, setDestination] = useState("rotterdam");
998
+ // fields the OUTPUT FORMS read — they live in their data sections
999
+ // (Fulfilment / Classification), EMPTY until filled; the Document set
1000
+ // below only marks their readiness
1001
+ const [deliveryAddress, setDeliveryAddress] = useState("");
1002
+ const [commodityCode, setCommodityCode] = useState("");
1003
+ const [hazardousGoods, setHazardousGoods] = useState(false);
1004
+ const [hazardClass, setHazardClass] = useState("");
1005
+
1006
+ // ── the DOCUMENT SET — the OUTPUT desk's state. Which forms are chosen
1007
+ // (the common ones pre-checked), the folded long tail, the two output
1008
+ // config fields, and the produced set itself. Regenerating REPLACES the
1009
+ // set — it is derived paperwork, not intake (the desk register above
1010
+ // stays the home of received files).
1011
+ const [chosenForms, setChosenForms] = useState<ReadonlySet<string>>(
1012
+ () => new Set(ALL_FORMS.filter((f) => f.common).map((f) => f.id)),
1013
+ );
1014
+ const toggleForm = (fid: string, on: boolean) =>
1015
+ setChosenForms((prev) => {
1016
+ const next = new Set(prev);
1017
+ if (on) next.add(fid);
1018
+ else next.delete(fid);
1019
+ return next;
1020
+ });
1021
+ const [unfolded, setUnfolded] = useState<ReadonlySet<string>>(new Set());
1022
+ const toggleFold = (gid: string) =>
1023
+ setUnfolded((prev) => {
1024
+ const next = new Set(prev);
1025
+ if (next.has(gid)) next.delete(gid);
1026
+ else next.add(gid);
1027
+ return next;
1028
+ });
1029
+ const [issuingOffice, setIssuingOffice] = useState("Haiphong branch");
1030
+ const [prefillSignDate, setPrefillSignDate] = useState(true);
1031
+ const [docSet, setDocSet] = useState<Doc[]>([]);
1032
+ // READINESS, derived live — which record fields the CHECKED forms still
1033
+ // miss. The fields live in their data sections above (Fulfilment /
1034
+ // Classification); the set marks each row's needs (muted while unchecked —
1035
+ // an eligibility note; warning once checked — the consequence) and the
1036
+ // gate reads ONCE as a co-located consequence Callout.
1037
+ const needValues: Record<NeedKey, string> = { delivery_address: deliveryAddress, commodity_code: commodityCode, hazard_class: hazardClass };
1038
+ const missingOf = (f: SetForm) => (f.needs ?? []).filter((k) => needValues[k].trim() === "");
1039
+ const isEligible = (f: SetForm) => f.id !== "c3" || hazardousGoods;
1040
+ const checkedForms = ALL_FORMS.filter((f) => isEligible(f) && chosenForms.has(f.id));
1041
+ const blockingNeeds = [...new Set(checkedForms.flatMap(missingOf))];
1042
+ // the fill panels' DRAFTS — keyed by field, SHARED between panels needing
1043
+ // the same field; Save commits onto the record (the panel then collapses
1044
+ // because the need resolved, and the field's home row shows the value)
1045
+ const [fillDrafts, setFillDrafts] = useState<Record<string, string>>({});
1046
+ // which rows have their fill editor OPEN — toggled ONLY by the row's
1047
+ // explicit "Add missing fields" trigger, never as a side effect of
1048
+ // checking (an undiscoverable expansion is no affordance)
1049
+ const [openFill, setOpenFill] = useState<ReadonlySet<string>>(new Set());
1050
+ const toggleFillOpen = (fid: string) =>
1051
+ setOpenFill((prev) => {
1052
+ const next = new Set(prev);
1053
+ if (next.has(fid)) next.delete(fid);
1054
+ else next.add(fid);
1055
+ return next;
1056
+ });
1057
+ const needSetters: Record<NeedKey, (v: string) => void> = { delivery_address: setDeliveryAddress, commodity_code: setCommodityCode, hazard_class: setHazardClass };
1058
+ const saveNeeds = (keys: NeedKey[]) => {
1059
+ for (const k of keys) needSetters[k]((fillDrafts[k] ?? "").trim());
1060
+ };
1061
+ // Generation is DETERMINISTIC template fill — NOT AI, so no AgentRun
1062
+ // theater: the CTA carries a brief loading state and the whole set
1063
+ // appears at once (a real app awaits its render call the same way).
1064
+ const [producing, setProducing] = useState(false);
1065
+ const runCreate = () => {
1066
+ setProducing(true);
1067
+ const chosen = checkedForms;
1068
+ setTimeout(() => {
1069
+ setDocSet(chosen.map((f) => ({ id: `set-${f.id}`, name: `${f.slug}_${code}.pdf`, mimeType: "application/pdf", kind: "PDF", sizeKB: f.sizeKB, added: "just now", addedAt: 999 })));
1070
+ setProducing(false);
1071
+ logActivity("file-stack", `Document set created — ${chosen.length} ${chosen.length === 1 ? "form" : "forms"}`, { description: chosen.map((f) => f.label).join(", ") });
1072
+ }, 900);
1073
+ };
1074
+ // Missing fields DEGRADE the outcome (those fields print blank, filled by
1075
+ // hand later) — they don't make it impossible, so Create stays ENABLED and
1076
+ // the press CONFIRMS instead (the gating law's degraded-but-valid case):
1077
+ // name what's missing, Cancel returns to the inline fixes.
1078
+ const createSet = () => {
1079
+ const notReady = checkedForms.filter((f) => missingOf(f).length > 0);
1080
+ if (notReady.length === 0) {
1081
+ runCreate();
1082
+ return;
1083
+ }
1084
+ Alert.alert(
1085
+ "Missing fields",
1086
+ `${notReady.map((f) => `${f.label} — ${missingOf(f).map((k) => NEEDS[k].label).join(", ")}`).join(". ")}. Those fields print blank.`,
1087
+ [
1088
+ { text: "Cancel", style: "cancel" },
1089
+ { text: "Generate anyway", onPress: runCreate },
1090
+ ],
1091
+ );
1092
+ };
1093
+ const removeFromSet = (docId: string) => setDocSet((prev) => prev.filter((d) => d.id !== docId));
1094
+
1095
+ // ── the DELIVERY RECEIPT — the QUICK-ISSUE form pattern: capture a
1096
+ // moment's facts, issue ONE document (vs the set's pick-from-registries).
1097
+ // Auto-stamped where the system knows the value (date, receipt no), the
1098
+ // rest prefilled from the record; every field is the right input.
1099
+ const [rcptDate, setRcptDate] = useState("2026-07-18");
1100
+ const [rcptHandler, setRcptHandler] = useState<string | null>("mem_02");
1101
+ const [rcptReference, setRcptReference] = useState("PO-2026-8841");
1102
+ const [rcptCondition, setRcptCondition] = useState("good");
1103
+ const [rcptNote, setRcptNote] = useState("");
1104
+ const [rcptDoc, setRcptDoc] = useState<Doc | null>(null);
1105
+ const [rcptProducing, setRcptProducing] = useState(false);
1106
+ const createReceipt = () => {
1107
+ setRcptProducing(true);
1108
+ setTimeout(() => {
1109
+ setRcptDoc({ id: "rcpt-1", name: `delivery-receipt_${code}.pdf`, mimeType: "application/pdf", kind: "PDF", sizeKB: 64, added: "just now", addedAt: 999 });
1110
+ setRcptProducing(false);
1111
+ logActivity("file-text", "Delivery receipt created — RCPT-2026-0715");
1112
+ }, 900);
1113
+ };
426
1114
 
427
1115
  // ── the handoff tasks — the current desk's checklist gates its handoff
428
1116
  const [tasks, setTasks] = useState<StageTask[]>(TASK_SEEDS);
@@ -466,7 +1154,7 @@ export function TplRecord() {
466
1154
  taskGroup === "desk"
467
1155
  ? DESKS.map((st) => ({
468
1156
  key: st.key,
469
- head: <Badge variant="dot" label={st.label} color={st.color} />,
1157
+ head: <Text size="xs" weight="semibold" color="muted">{st.label}</Text>,
470
1158
  items: taskPool.filter((t) => t.stage === st.key),
471
1159
  desk: st.key,
472
1160
  ghosts: suggesting ? SUGGESTED_TASKS[st.key].filter((l) => !tasks.some((t) => t.label === l) && !dismissed.includes(l)) : [],
@@ -481,27 +1169,17 @@ export function TplRecord() {
481
1169
  { key: "done", head: <Text size="xs" weight="semibold" color="muted">Done</Text>, items: taskPool.filter((t) => t.done), desk: null, ghosts: [] },
482
1170
  ].filter((g) => g.items.length > 0)
483
1171
  : [{ key: "all", head: null, items: taskPool, desk: null, ghosts: [] }];
484
- const remaining = tasks.filter((t) => t.stage === stage && !t.done).length;
485
-
486
- // ── preferences
487
- const [notifyCustomer, setNotifyCustomer] = useState(true);
488
- const [partialDelivery, setPartialDelivery] = useState(false);
489
-
490
1172
  const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
491
1173
  value: c.id,
492
1174
  label: c.name,
493
1175
  data: c,
494
1176
  }));
495
1177
 
496
- const patchCustomer = (cid: string, patch: Partial<Customer>) =>
497
- setCustomers((prev) => prev.map((c) => (c.id === cid ? { ...c, ...patch } : c)));
498
-
499
1178
  // Find-or-create resolution: an existing pick attaches; the custom row opens
500
1179
  // the create Dialog, prefilled with the query.
501
1180
  const onPickCustomer = (opt: PickerOption<string, Customer>) => {
502
1181
  if (customers.some((c) => c.id === opt.value)) {
503
1182
  setCustomerId(opt.value);
504
- setEditingCustomer(false);
505
1183
  return;
506
1184
  }
507
1185
  setCustDraft({ name: opt.value, taxId: "", contact: "", city: "" });
@@ -523,18 +1201,9 @@ export function TplRecord() {
523
1201
  setCustomers((prev) => [...prev, c]);
524
1202
  setCustomerId(c.id);
525
1203
  setCustDraft(null);
526
- setEditingCustomer(false);
527
- };
528
-
529
- // The registry lookup — fills Contact + City off the customer's tax ID.
530
- const fetchDetails = async (c: Customer) => {
531
- setFetching(true);
532
- const details = await lookupRegistry(c.taxId.trim());
533
- patchCustomer(c.id, details);
534
- setFetching(false);
535
1204
  };
536
1205
 
537
- // The same lookup for the create Dialog — fills the draft's fields.
1206
+ // The registry lookup for the create Dialog — fills the draft's fields.
538
1207
  const fetchIntoDraft = async () => {
539
1208
  if (!custDraft) return;
540
1209
  setFetching(true);
@@ -543,31 +1212,6 @@ export function TplRecord() {
543
1212
  setFetching(false);
544
1213
  };
545
1214
 
546
- // "New order" is ONE CLICK — a fresh Draft, refined entirely on this surface.
547
- const newOrder = () => {
548
- setCode(`RC-2026-0${420 + (id.current += 1)}`);
549
- setStage("sales");
550
- setCustomerId(null);
551
- setCustDraft(null);
552
- setEditingCustomer(false);
553
- setOrderDate("2026-07-04");
554
- setDeliverBy("");
555
- setTerms("30");
556
- setPriority("standard");
557
- setWarehouse("central");
558
- setReference("");
559
- setWeight(null);
560
- setSalesOwner(null);
561
- setInvoices(freshInvoices());
562
- setDeposit(0);
563
- setFiles([]);
564
- setTasks([]);
565
- setDismissed([]);
566
- setNewTask("");
567
- setNotifyCustomer(true);
568
- setPartialDelivery(false);
569
- };
570
-
571
1215
  const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
572
1216
  setInvoices((prev) =>
573
1217
  prev.map((inv) =>
@@ -593,6 +1237,7 @@ export function TplRecord() {
593
1237
  const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
594
1238
  setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
595
1239
  setConfirmIssue(null);
1240
+ logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
596
1241
  };
597
1242
 
598
1243
  const printReceipt = () => {
@@ -609,10 +1254,6 @@ export function TplRecord() {
609
1254
 
610
1255
  // Open tasks INFORM the handoff, they never block it — the count warns and
611
1256
  // carries over to the next desk (or stays open on the closed record).
612
- const taskHint =
613
- stage === "accounting"
614
- ? `${remaining} open ${remaining === 1 ? "task stays" : "tasks stay"} open on the closed record.`
615
- : `${remaining} open ${remaining === 1 ? "task carries" : "tasks carry"} over to the next desk.`;
616
1257
  const gate =
617
1258
  stage === "sales"
618
1259
  ? { cta: "Hand off to Operations", next: "operations" as Stage }
@@ -621,18 +1262,89 @@ export function TplRecord() {
621
1262
  : stage === "accounting"
622
1263
  ? { cta: "Close record", next: "closed" as Stage }
623
1264
  : null;
624
- const prevStage: Stage | null = stage === "operations" ? "sales" : stage === "accounting" ? "operations" : null;
1265
+
1266
+ // ── the handoff flow — the dialog's fields, the TRAIL of marks, and the
1267
+ // SIBLING records the handoffs created (shown as LinkedRecordCards)
1268
+ const [handoffOpen, setHandoffOpen] = useState(false);
1269
+ const [handoffAssignee, setHandoffAssignee] = useState<string | null>(null);
1270
+ const [handoffNote, setHandoffNote] = useState("");
1271
+ const [handoffs, setHandoffs] = useState<TimelineItem[]>([]);
1272
+ const [siblings, setSiblings] = useState<SiblingRecord[]>([]);
1273
+ const [siblingOpen, setSiblingOpen] = useState<string | null>(null);
1274
+ const openSibling = siblingOpen ? siblings.find((sb) => sb.id === siblingOpen) ?? null : null;
1275
+ // ── the customer detail drawer (the linked record's full info)
1276
+ const [customerOpen, setCustomerOpen] = useState(false);
1277
+ // Confirm MARKS the handoff — whom · where · the note — and, in a real app,
1278
+ // CREATES the next desk's record on ITS table, linked here. The mark shows
1279
+ // that linked sibling as a reference; its summary lives on the sibling.
1280
+ const confirmHandoff = () => {
1281
+ if (!gate || gate.next === "closed" || handoffAssignee == null) return;
1282
+ const to = TEAM.find((m) => m.id === handoffAssignee);
1283
+ const at = new Date();
1284
+ const siblingCode = `${gate.next === "operations" ? "OP" : "AC"}-2026-00${(siblingSeq += 1)}`;
1285
+ setSiblings((prev) => [
1286
+ { id: siblingCode, code: siblingCode, desk: stageOf(gate.next).label, assignee: to?.name ?? "", note: handoffNote.trim(), at: stamp(at) },
1287
+ ...prev,
1288
+ ]);
1289
+ setHandoffs((prev) => [
1290
+ {
1291
+ id: `h_${prev.length + 1}`,
1292
+ icon: "send",
1293
+ iconColor: colors.blue[500],
1294
+ label: `Handed off to ${stageOf(gate.next).label} — ${to?.name ?? ""}`,
1295
+ description: handoffNote.trim() || undefined,
1296
+ right: <Text size="xs" color="muted">{stamp(at)}</Text>,
1297
+ },
1298
+ ...prev,
1299
+ ]);
1300
+ logActivity("send", `Handed off to ${stageOf(gate.next).label} — ${to?.name ?? ""}`, { iconColor: colors.blue[500], description: `${siblingCode} created and linked` });
1301
+ setStage(gate.next);
1302
+ setHandoffOpen(false);
1303
+ setHandoffAssignee(null);
1304
+ setHandoffNote("");
1305
+ };
1306
+ // RECALL — the undo: withdraws the sibling (a real app cancels the sibling
1307
+ // record, and gates recall on it being UNTOUCHED at its desk) and returns
1308
+ // the desk. History is never erased — the trail keeps the original mark and
1309
+ // gains a recall mark; the handoff CTA comes back, so redo is possible.
1310
+ const recallHandoff = (sb: SiblingRecord) => {
1311
+ Alert.alert(`Recall the handoff?`, `${sb.code} is withdrawn from ${sb.desk} and the record returns to Sales. The trail keeps both marks.`, [
1312
+ { text: "Cancel", style: "cancel" },
1313
+ {
1314
+ text: "Recall",
1315
+ style: "destructive",
1316
+ onPress: () => {
1317
+ setSiblings((prev) => prev.filter((x) => x.id !== sb.id));
1318
+ setSiblingOpen(null);
1319
+ setStage("sales");
1320
+ setHandoffs((prev) => [
1321
+ { id: `h_${prev.length + 1}`, icon: "x", iconColor: colors.zinc[500], label: `Handoff recalled — ${sb.code} withdrawn`, right: <Text size="xs" color="muted">{stamp(new Date())}</Text> },
1322
+ ...prev,
1323
+ ]);
1324
+ logActivity("x", `Handoff recalled — ${sb.code} withdrawn from ${sb.desk}`);
1325
+ },
1326
+ },
1327
+ ]);
1328
+ };
625
1329
 
626
1330
  // ── the outline rail — jump to a section instead of scrolling the long page
1331
+ // GENERAL leads the rail — the item's core details; everything after it is
1332
+ // supplementary (discussion, work, documents, parties, money, lifecycle).
627
1333
  const SECTIONS = [
628
- { key: "details", label: "Details", icon: "file-text" },
1334
+ { key: "general", label: "General", icon: "file-text" },
1335
+ { key: "comments", label: "Comments", icon: "message-square" },
1336
+ { key: "tasks", label: "Tasks", icon: "list-checks" },
1337
+ { key: "documents", label: "Documents", icon: "folder-closed" },
629
1338
  { key: "customer", label: "Customer", icon: "building-2" },
1339
+ { key: "fees", label: "Fees", icon: "receipt" },
630
1340
  { key: "billing", label: "Billing", icon: "credit-card" },
631
- { key: "files", label: "Files", icon: "folder-closed" },
632
- { key: "tasks", label: "Tasks", icon: "list-checks" },
633
- { key: "preferences", label: "Preferences", icon: "sliders-horizontal" },
1341
+ { key: "docset", label: "Document set", icon: "file-stack" },
1342
+ { key: "receipt", label: "Delivery receipt", icon: "log-in" },
1343
+ { key: "activity", label: "Activity", icon: "clock" },
1344
+ { key: "handoff", label: "Handoff", icon: "send" },
1345
+ { key: "danger", label: "Danger zone", icon: "circle-alert" },
634
1346
  ] as const;
635
- const nav = useSectionNav(["details", "customer", "billing", "files", "tasks", "preferences"] as const);
1347
+ const nav = useSectionNav(["general", "comments", "tasks", "documents", "customer", "fees", "billing", "docset", "receipt", "activity", "handoff", "danger"] as const);
636
1348
  // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
637
1349
  // it opens a full-page section picker (Escape / the close control dismiss).
638
1350
  const [sectionsOpen, setSectionsOpen] = useState(false);
@@ -659,7 +1371,10 @@ export function TplRecord() {
659
1371
  // Narrow: a pinned bar names the CURRENT section (the spy keeps it
660
1372
  // honest while scrolling); tapping opens the full-page section picker —
661
1373
  // no tab strip the thumb has to scroll.
662
- <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: 12, paddingVertical: 6, flexDirection: "row" }}>
1374
+ <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: 12, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
1375
+ {/* narrow keeps the standard too — the bare back glyph beside the
1376
+ section bar (the rail that would carry it is collapsed). */}
1377
+ <BackButton onPress={() => {}} />
663
1378
  <Button
664
1379
  title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
665
1380
  color="secondary"
@@ -677,363 +1392,334 @@ export function TplRecord() {
677
1392
  scroll like everything else. */}
678
1393
  <View style={{ width: "100%", maxWidth: wide ? GUTTER * 2 + CONTENT_MAX : CONTENT_MAX, alignSelf: "center", flexDirection: "row" }}>
679
1394
  {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
1395
+ {booting ? (
1396
+ /* first paint — Skeleton MIRRORS the final layout (summary →
1397
+ key facts → the documents table), never a lone spinner. */
1398
+ <View style={{ flex: 1, minWidth: 0, gap: 40 }}>
1399
+ <View style={{ gap: 12 }}>
1400
+ <Skeleton width="38%" height={30} />
1401
+ <Skeleton width="26%" height={16} />
1402
+ </View>
1403
+ <View style={{ gap: 12 }}>
1404
+ <Skeleton width="52%" height={20} />
1405
+ <Skeleton width="46%" height={20} />
1406
+ <Skeleton width="55%" height={20} />
1407
+ <Skeleton width="43%" height={20} />
1408
+ </View>
1409
+ <Skeleton height={220} radius={10} />
1410
+ </View>
1411
+ ) : (
1412
+ <>
680
1413
  {/* the page column is a SectionStack — it owns the 56px beat + the
681
1414
  hairline BETWEEN top-level blocks (a conditional block that renders
682
1415
  null never leaves a stray divider) */}
683
1416
  <SectionStack style={{ flex: 1, minWidth: 0 }}>
684
- {/* header band — app bar + summary + key facts read as ONE block */}
685
- <View style={{ gap: 32 }}>
686
- {/* app bar CREATE is one click; the surface is where everything is refined */}
687
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 10, rowGap: 8 }}>
688
- <Text size="sm" color="muted">Records <Text size="sm" color="muted">/</Text> <Text size="sm" weight="medium" color="default" tabular>{code}</Text></Text>
689
- <View style={{ flex: 1 }} />
690
- <Button title="New record" color="secondary" icon="plus" onPress={newOrder} />
691
- </View>
692
-
1417
+ {/* header band — identity · attention · a light summary. No stage here
1418
+ (Tasks carries progress, Handoff carries the desk), no breadcrumb,
1419
+ no create CTA: back lives in the panel, creation belongs to the
1420
+ REGISTER (the list owns "new"), the record page only edits. */}
1421
+ <View style={{ gap: 16 }}>
693
1422
  <RecordSummary
694
1423
  title={code}
695
1424
  subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(" · ") : "No customer"}
696
- status={<Badge label={stageOf(stage).label} color={stageOf(stage).color} />}
697
- metric={{ label: "To collect", value: formatMoney(grandTotal), tone: stage === "closed" ? "success" : "default" }}
698
1425
  />
699
-
700
- {/* the header's key facts a DetailTable with the SAME columns as the
701
- Details table below (trailingWidth reserved even though no fact has
702
- a trailing), so the two parts sit on one page-wide grid */}
703
- <DetailTable labelWidth={150} trailingWidth={88}>
704
- <DetailRow label="Sales owner">
705
- <InlineMemberSelect
706
- members={TEAM}
707
- value={salesOwner}
708
- onSave={persist(setSalesOwner)}
709
- placeholder="Assign…"
710
- accessibilityLabel="Sales owner"
711
- />
712
- </DetailRow>
713
- <DetailRow label="Opened">
714
- <InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
715
- </DetailRow>
716
- <DetailRow label="Due">
717
- <InlineDatePicker value={deliverBy} onSave={persist(setDeliverBy)} locale="en-US" accessibilityLabel="Due date" placeholder="Set a due date…" />
718
- </DetailRow>
719
- <DetailRow label="Payment terms">
720
- <InlineSelect value={terms} options={TERMS} onSave={persist(setTerms)} accessibilityLabel="Payment terms" />
721
- </DetailRow>
722
- </DetailTable>
723
- </View>
724
-
725
- {/* ONE DetailTable = the record's field grid: label · editor · trailing
726
- columns set once on the parent, every row aligned like a table —
727
- editors share one width even when only some rows carry a trailing
728
- action / badge; read-only values are InlineStatic in the same
729
- column, aligned to the pixel */}
730
- <View onLayout={nav.register("details")}>
731
- <Section>
732
- <SectionHeading>
733
- <SectionHeadingTitle description="The order's core facts — reference, dates, and the amounts every desk reads first.">Details</SectionHeadingTitle>
734
- <OwnerTag stage="sales" />
735
- </SectionHeading>
736
- <DetailTable labelWidth={150} trailingWidth={88}>
737
- <DetailRow label="Reference" trailing={<Button title="Copy" color="secondary" onPress={() => {}} />}>
738
- <InlineTextInput value={reference} onSave={persist(setReference)} placeholder="Add buyer's reference…" accessibilityLabel="Reference" />
739
- </DetailRow>
740
- <DetailRow label="Priority">
741
- <InlineSelect
742
- value={priority}
743
- onSave={persist(setPriority)}
744
- options={PRIORITY}
745
- accessibilityLabel="Priority"
746
- renderOptionContent={(o) => (
747
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
748
- <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PRIORITY_DOT[o.value] }} />
749
- <Text size="sm">{o.label}</Text>
750
- </View>
751
- )}
752
- />
753
- </DetailRow>
754
- <DetailRow label="Location">
755
- <InlineSelect value={warehouse} onSave={persist(setWarehouse)} options={WAREHOUSES} accessibilityLabel="Location" />
756
- </DetailRow>
757
- <DetailRow label="Total weight">
758
- <InlineNumberInput
759
- value={weight}
760
- onSave={persist(setWeight)}
761
- min={0}
762
- format={(v) => (v == null ? "" : `${v.toLocaleString("en-US")} kg`)}
763
- placeholder="Add the weight…"
764
- accessibilityLabel="Total weight"
765
- />
766
- </DetailRow>
767
- <DetailRow label="Record ID" trailing={<Badge label="System" color="zinc" />}>
768
- <InlineStatic value={code} tabular />
769
- </DetailRow>
770
- </DetailTable>
771
- </Section>
1426
+ {/* the record's ATTENTION state, on top — record-scoped, so it reads
1427
+ as a Callout here (field-scoped state stays on its field) */}
1428
+ {stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
1429
+ <Callout tone="warning">
1430
+ <CalloutText>Overdue the due date has passed. Chase the delivery or move the date.</CalloutText>
1431
+ </Callout>
1432
+ ) : null}
1433
+ {/* the light summary — what's on this record, one quiet line */}
1434
+ <SummaryLine
1435
+ items={[
1436
+ { label: `of ${tasks.length} tasks done`, value: tasks.filter((t) => t.done).length },
1437
+ { label: "documents", value: files.length },
1438
+ { label: "to collect", value: grandTotal, format: "currency", compact: true },
1439
+ ]}
1440
+ />
772
1441
  </View>
773
1442
 
774
- {/* CUSTOMERTWO states, no swap mode. Attached: the read-only-first
775
- card (identity + editable fields Tax ID with the registry Fetch
776
- that fills Contact + City, which are inline-editable too); Remove
777
- detaches the LINK (the customer stays in the book) and drops you to
778
- the empty state. Empty: the find-or-create search; the custom row
779
- opens the create Dialog. */}
780
- <View onLayout={nav.register("customer")}>
1443
+ {/* GENERALthe MAIN details, FIRST section: the core facts of the
1444
+ item; every other section is supplementary. The body is a
1445
+ SubsectionStack (space-only beat): a headingless LEAD group (the
1446
+ facts every desk reads first) then the named groups the rail
1447
+ stays sections-only, grouping lives INSIDE the section. Every
1448
+ group's DetailTable repeats the SAME labelWidth/trailingWidth, so
1449
+ the grid stays aligned through the groups; read-only values are
1450
+ InlineStatic in the same column, aligned to the pixel. */}
1451
+ <View onLayout={nav.register("general")}>
781
1452
  <Section>
782
1453
  <SectionHeading>
783
- <SectionHeadingTitle description="The bill-to partyits tax ID gates invoicing below.">Customer</SectionHeadingTitle>
784
- <OwnerTag stage="sales" />
785
- {customer && !editingCustomer ? (
786
- <Button title="Edit" color="secondary" icon="pencil" onPress={() => setEditingCustomer(true)} />
787
- ) : null}
1454
+ <SectionHeadingTitle description="The core facts of the order what every desk reads first.">General</SectionHeadingTitle>
788
1455
  </SectionHeading>
789
- {customer ? (
790
- <View style={{ gap: 10 }}>
791
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
792
- <Icon name="building-2" size={16} color={colors.zinc[500]} />
793
- <Text weight="medium">{customer.name}</Text>
794
- <Text size="xs" color="muted" tabular>{customer.code}</Text>
795
- </View>
796
- {editingCustomer ? (
797
- <DetailTable labelWidth={150} trailingWidth={88}>
798
- <DetailRow
799
- label="Tax ID"
800
- trailing={
801
- <Button
802
- title="Fetch"
803
- color="secondary"
804
- loading={fetching}
805
- disabled={!TAX_ID_RE.test(customer.taxId.trim())}
806
- onPress={() => fetchDetails(customer)}
807
- />
808
- }
809
- >
810
- <InlineTextInput
811
- value={customer.taxId}
812
- onSave={(v: string) => {
813
- if (v.trim() !== "" && !TAX_ID_RE.test(v.trim())) throw new Error("Enter a 10- or 13-digit tax ID.");
814
- return persist((next: string) => patchCustomer(customer.id, { taxId: next.trim() }))(v);
815
- }}
816
- placeholder="Add tax ID…"
817
- accessibilityLabel="Tax ID"
818
- />
819
- </DetailRow>
820
- <DetailRow label="Contact">
821
- <InlineTextInput
822
- value={customer.contact}
823
- onSave={persist((v: string) => patchCustomer(customer.id, { contact: v }))}
824
- placeholder="Add contact…"
825
- accessibilityLabel="Contact"
826
- />
827
- </DetailRow>
828
- <DetailRow label="City">
829
- <InlineTextInput
830
- value={customer.city}
831
- onSave={persist((v: string) => patchCustomer(customer.id, { city: v }))}
832
- placeholder="Add city…"
833
- accessibilityLabel="City"
1456
+ <SubsectionStack>
1457
+ <Subsection>
1458
+ <DetailTable labelWidth={150} trailingWidth={88}>
1459
+ <DetailRow label="Sales owner">
1460
+ <InlineMemberSelect
1461
+ members={TEAM}
1462
+ value={salesOwner}
1463
+ onSave={persist(setSalesOwner)}
1464
+ placeholder="Assign…"
1465
+ accessibilityLabel="Sales owner"
1466
+ />
1467
+ </DetailRow>
1468
+ <DetailRow label="Opened">
1469
+ <InlineDatePicker value={orderDate} onSave={persist(setOrderDate)} locale="en-US" accessibilityLabel="Opened date" />
1470
+ </DetailRow>
1471
+ <DetailRow label="Due">
1472
+ <InlineDatePicker value={deliverBy} onSave={persist(setDeliverBy)} locale="en-US" accessibilityLabel="Due date" placeholder="Set a due date…" />
1473
+ </DetailRow>
1474
+ {/* a LONG description — it wraps in the value column, the
1475
+ label stays pinned to the control line */}
1476
+ <DetailRow label="Payment terms" description="Counted from the invoice issue date, not delivery. The terms agreed on the signed quote apply to every invoice on this order — change them before issuing; an issued invoice keeps the terms it was issued under.">
1477
+ <InlineSelect value={terms} options={TERMS} onSave={persist(setTerms)} accessibilityLabel="Payment terms" />
1478
+ </DetailRow>
1479
+ </DetailTable>
1480
+ </Subsection>
1481
+ <Subsection>
1482
+ <SubsectionHeading>
1483
+ <SubsectionHeadingTitle>Order</SubsectionHeadingTitle>
1484
+ </SubsectionHeading>
1485
+ <DetailTable labelWidth={150} trailingWidth={88}>
1486
+ {/* a persistent field DESCRIPTION — under the value, same
1487
+ vocabulary as FormField; state → `error` / a co-located
1488
+ Callout. Everything EXPLICIT — no hidden ⓘ gloss. */}
1489
+ <DetailRow label="Reference" description="Printed on every document" trailing={<Button title="Copy" color="secondary" onPress={() => {}} />}>
1490
+ <InlineTextInput value={reference} onSave={persist(setReference)} placeholder="Add buyer's reference…" accessibilityLabel="Reference" />
1491
+ </DetailRow>
1492
+ <DetailRow label="Priority">
1493
+ <InlineSelect
1494
+ value={priority}
1495
+ onSave={persist(setPriority)}
1496
+ options={PRIORITY}
1497
+ accessibilityLabel="Priority"
1498
+ renderOptionContent={(o) => (
1499
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1500
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PRIORITY_DOT[o.value] }} />
1501
+ <Text size="sm">{o.label}</Text>
1502
+ </View>
1503
+ )}
1504
+ />
1505
+ </DetailRow>
1506
+ </DetailTable>
1507
+ </Subsection>
1508
+ <Subsection>
1509
+ <SubsectionHeading>
1510
+ <SubsectionHeadingTitle>Fulfilment</SubsectionHeadingTitle>
1511
+ </SubsectionHeading>
1512
+ <DetailTable labelWidth={150} trailingWidth={88}>
1513
+ <DetailRow label="Location" description="The site that picks and ships the order">
1514
+ <InlineSelect value={warehouse} onSave={persist(setWarehouse)} options={WAREHOUSES} accessibilityLabel="Location" />
1515
+ </DetailRow>
1516
+ {/* the editor's own TRANSIENT save error — throw from onSave
1517
+ and the inline editor shows it, stays in edit mode, re-arms
1518
+ (try a weight over 30.000) */}
1519
+ <DetailRow label="Total weight" description="Packaging included">
1520
+ <InlineNumberInput
1521
+ value={weight}
1522
+ onSave={(v: number | null) => {
1523
+ if (v != null && v > 30_000) throw new Error("Over the 30.000 kg road limit — split the order.");
1524
+ return persist(setWeight)(v);
1525
+ }}
1526
+ min={0}
1527
+ format={(v) => (v == null ? "" : `${v.toLocaleString("en-US")} kg`)}
1528
+ placeholder="Add the weight…"
1529
+ accessibilityLabel="Total weight"
1530
+ />
1531
+ </DetailRow>
1532
+ {/* a field an OUTPUT FORM reads — the description names its
1533
+ consumer; the Document set below marks it while missing */}
1534
+ <DetailRow label="Delivery address" description="The consignee's dock — printed on the delivery note">
1535
+ <InlineTextInput value={deliveryAddress} onSave={persist(setDeliveryAddress)} placeholder="Add the delivery address…" accessibilityLabel="Delivery address" />
1536
+ </DetailRow>
1537
+ </DetailTable>
1538
+ </Subsection>
1539
+ {/* CLASSIFICATION — the right-input-per-field law, worked. A field
1540
+ gets the control its SHAPE wants, not a default text box:
1541
+ · ≤5 exclusive options the user should SEE → RadioPicker
1542
+ (column when options carry descriptions, row when short)
1543
+ · a longer closed list → InlineSelect (rich options carry a
1544
+ description line — it shows in the resting row too)
1545
+ · a pick from a big REGISTRY → InlineSelect searchable (the
1546
+ search lives in the popover; the bare Combobox is the FORM-
1547
+ surface control — find-or-create / attach flows)
1548
+ · a boolean → CheckboxInput (Hazardous goods below)
1549
+ · a DEPENDENT field renders only while its parent value makes
1550
+ it real — no disabled ghost rows.
1551
+ Radio/checkbox/search are PERSISTENT controls (the control is
1552
+ the best display); prose-shaped values stay inline editors. */}
1553
+ <Subsection>
1554
+ <SubsectionHeading>
1555
+ <SubsectionHeadingTitle>Classification</SubsectionHeadingTitle>
1556
+ </SubsectionHeading>
1557
+ <DetailTable labelWidth={150} trailingWidth={88}>
1558
+ <DetailRow label="Service level">
1559
+ <InlineSelect
1560
+ value={serviceLevel}
1561
+ options={SERVICE_LEVELS}
1562
+ onSave={persist(setServiceLevel)}
1563
+ accessibilityLabel="Service level"
1564
+ renderOptionContent={(o) => (
1565
+ <View style={{ gap: 1, flexShrink: 1 }}>
1566
+ <Text size="sm">{o.label}</Text>
1567
+ <Text size="xs" color="muted">{SERVICE_LEVEL_DESC[o.value]}</Text>
1568
+ </View>
1569
+ )}
1570
+ />
1571
+ </DetailRow>
1572
+ <DetailRow label="Fulfilment status">
1573
+ <RadioPicker
1574
+ accessibilityLabel="Fulfilment status"
1575
+ value={fulfilmentStatus}
1576
+ onValueChange={setFulfilmentStatus}
1577
+ options={[
1578
+ { value: "ready", label: "Ready at the warehouse" },
1579
+ { value: "production", label: "In production", description: "Confirmed ready by the ship date." },
1580
+ { value: "awaiting", label: "Awaiting inbound supply" },
1581
+ { value: "partial", label: "Partial", description: "Ships in two lots — the rest follows on the next run." },
1582
+ ]}
1583
+ />
1584
+ </DetailRow>
1585
+ <DetailRow label="Insurance">
1586
+ <RadioPicker
1587
+ accessibilityLabel="Insurance"
1588
+ direction="row"
1589
+ value={insurance}
1590
+ onValueChange={setInsurance}
1591
+ options={[
1592
+ { value: "none", label: "None" },
1593
+ { value: "standard", label: "Standard" },
1594
+ { value: "all_risk", label: "All-risk" },
1595
+ ]}
1596
+ />
1597
+ </DetailRow>
1598
+ {insurance !== "none" ? (
1599
+ <DetailRow label="Insured value" description="The cargo value declared to the insurer">
1600
+ <InlineNumberInput
1601
+ value={insuredValue}
1602
+ onSave={persist(setInsuredValue)}
1603
+ min={0}
1604
+ format={(v) => (v == null ? "" : formatMoney(v))}
1605
+ placeholder="Declare the value…"
1606
+ accessibilityLabel="Insured value"
834
1607
  />
835
1608
  </DetailRow>
836
- </DetailTable>
837
- ) : (
838
- // At REST the card is read-only flat InlineStatic values in
839
- // the SAME columns, so entering Edit never shifts the grid.
840
- <DetailTable labelWidth={150} trailingWidth={88}>
841
- <DetailRow label="Tax ID">
842
- <InlineStatic value={customer.taxId || "—"} tabular />
843
- </DetailRow>
844
- <DetailRow label="Contact">
845
- <InlineStatic value={customer.contact || "—"} />
846
- </DetailRow>
847
- <DetailRow label="City">
848
- <InlineStatic value={customer.city || "—"} />
1609
+ ) : null}
1610
+ {/* a registry pick on a RECORD surface stays an INLINE editor —
1611
+ `InlineSelect searchable` (the search lives in the popover);
1612
+ the bare `Combobox` is the FORM-surface control (find-or-
1613
+ create / attach flows, e.g. the Customer section) */}
1614
+ <DetailRow label="Destination">
1615
+ <InlineSelect
1616
+ value={destination}
1617
+ options={DESTINATIONS}
1618
+ onSave={persist(setDestination)}
1619
+ searchable
1620
+ accessibilityLabel="Destination"
1621
+ renderOptionContent={(o) => (
1622
+ <View style={{ gap: 1, flexShrink: 1 }}>
1623
+ <Text size="sm">{o.label}</Text>
1624
+ {o.data ? <Text size="xs" color="muted">{o.data.country}</Text> : null}
1625
+ </View>
1626
+ )}
1627
+ />
1628
+ </DetailRow>
1629
+ {/* fields the OUTPUT FORMS read — each description names its
1630
+ consumers, so an empty value explains itself */}
1631
+ <DetailRow label="Commodity code" description="Customs tariff heading — the customs declaration and certificate of origin read it">
1632
+ <InlineTextInput value={commodityCode} onSave={persist(setCommodityCode)} placeholder="e.g. 4415.20…" accessibilityLabel="Commodity code" />
1633
+ </DetailRow>
1634
+ {/* a record BOOLEAN is a persistent CheckboxInput, and its
1635
+ dependents follow the dependent-field law */}
1636
+ <DetailRow label="Hazardous goods">
1637
+ <CheckboxInput accessibilityLabel="Hazardous goods" checked={hazardousGoods} onChange={setHazardousGoods} />
1638
+ </DetailRow>
1639
+ {hazardousGoods ? (
1640
+ <DetailRow label="Hazard class" description="Printed on the hazardous goods note">
1641
+ <InlineTextInput value={hazardClass} onSave={persist(setHazardClass)} placeholder="e.g. Class 3…" accessibilityLabel="Hazard class" />
849
1642
  </DetailRow>
850
- </DetailTable>
851
- )}
852
- {/* the card's closing row — Remove (destructive) low-left at
853
- rest; while editing, Done commits out at the bottom right */}
854
- {editingCustomer ? (
855
- <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
856
- <Button title="Done" color="primary" onPress={() => setEditingCustomer(false)} />
857
- </View>
858
- ) : (
859
- <View style={{ flexDirection: "row" }}>
860
- <Button title="Remove" color="danger" icon="x" onPress={() => setCustomerId(null)} />
861
- </View>
862
- )}
863
- </View>
864
- ) : (
865
- <View style={{ gap: 10 }}>
866
- <Combobox
867
- options={customerOptions}
868
- onValueChange={onPickCustomer}
869
- getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
870
- reflectSelection={false}
871
- allowCustom
872
- customOptionPlacement="top"
873
- customOptionLabel={(q) => `Create new customer “${q}”`}
874
- >
875
- <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
876
- <ComboboxContent emptyText="No customer matches — type a name to create one" />
877
- </Combobox>
878
- <Text size="xs" color="muted">
879
- No match? Pick “Create new customer …” to add one without leaving the order.
880
- </Text>
881
- </View>
882
- )}
1643
+ ) : null}
1644
+ </DetailTable>
1645
+ </Subsection>
1646
+ {/* system-managed identifiers get a HOME, not a per-row badge */}
1647
+ <Subsection>
1648
+ <SubsectionHeading>
1649
+ <SubsectionHeadingTitle>System</SubsectionHeadingTitle>
1650
+ </SubsectionHeading>
1651
+ <DetailTable labelWidth={150} trailingWidth={88}>
1652
+ <DetailRow label="Record ID">
1653
+ <InlineStatic value={code} tabular />
1654
+ </DetailRow>
1655
+ </DetailTable>
1656
+ </Subsection>
1657
+ </SubsectionStack>
883
1658
  </Section>
884
1659
  </View>
885
1660
 
886
- {/* BILLINGinvoice documents on the record, in the inline vocabulary.
887
- The issue action lives in each band's header; ONE section-level
888
- callout explains what blocks issuing (no per-band totals, no
889
- per-band reasons the collect band owns the number). */}
890
- <View onLayout={nav.register("billing")}>
1661
+ {/* COMMENTSthe first SUPPLEMENTARY section (General owns the core
1662
+ details): a coworker's note is the first thing to read on a record
1663
+ someone else touched. CommentList (author-only
1664
+ edit/delete via currentMemberId) + THE kit Composer; an app
1665
+ wires `useComments()` + `useMembers()` to the same shapes. */}
1666
+ <View onLayout={nav.register("comments")}>
891
1667
  <Section>
892
1668
  <SectionHeading>
893
- <SectionHeadingTitle description="Each invoice owns its charge lines a charge never lives apart from the document it bills on.">Billing</SectionHeadingTitle>
894
- <OwnerTag stage="accounting" />
1669
+ <SectionHeadingTitle description="The record's discussionevery question and decision stays with the order.">Comments</SectionHeadingTitle>
895
1670
  </SectionHeading>
896
-
897
- {stage === "sales" || stage === "operations" ? (
898
- <Callout tone="info">
899
- <CalloutText>Billing opens when the record reaches Accounting hand off above.</CalloutText>
900
- </Callout>
901
- ) : customer === null ? (
902
- <Callout tone="warning">
903
- <CalloutText>Attach a customer above to issue invoices.</CalloutText>
904
- </Callout>
905
- ) : !taxIdValid ? (
906
- <Callout tone="warning">
907
- <CalloutText>{customer.name} has no valid tax ID — add it in the Customer section to issue invoices.</CalloutText>
908
- </Callout>
909
- ) : null}
910
-
911
- {/* the section's GROUPS — bands · collect · deposit — divide via the
912
- SubsectionStack law (24 + hairline), no hand-rolled dividers */}
913
- <SubsectionStack>
914
- <View style={{ gap: 10 }}>
915
- {invoices.map((inv) => (
916
- <InvoiceBand
917
- key={inv.key}
918
- inv={inv}
919
- gateReason={
920
- stage === "sales" || stage === "operations"
921
- ? "Billing opens at the Accounting stage."
922
- : customer === null
923
- ? "Attach a customer to issue."
924
- : !taxIdValid
925
- ? "Add the customer's tax ID to issue."
926
- : ""
927
- }
928
- onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
929
- onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
930
- onIssue={setConfirmIssue}
1671
+ <CommentList
1672
+ comments={comments}
1673
+ currentMemberId="mem_01"
1674
+ resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
1675
+ onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
1676
+ onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
1677
+ /* the file-capable edit form — the same injection the product uses */
1678
+ renderEditForm={(p) => <CommentFileEditForm {...p} />}
1679
+ /* attachments render as the SAME thumbnail grid the product uses —
1680
+ press previews in the gallery (the desk's preview state) */
1681
+ renderFiles={(files) => (
1682
+ <FileGrid
1683
+ files={files.map(commentFileToDisplay)}
1684
+ itemSize={84}
1685
+ onFilePress={(f) => setPreview({ files: [f], index: 0 })}
931
1686
  />
932
- ))}
933
- </View>
1687
+ )}
1688
+ /* compact timestamp — the product formats RELATIVE ("2 days ago",
1689
+ date-fns); a template stays dependency-free and deterministic */
1690
+ formatTimestamp={(iso) => { const d = new Date(iso); return `${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`; }}
1691
+ />
1692
+ {/* THE kit Composer — the one composing surface everywhere (chat,
1693
+ agent prompts, comments): attach via actionsButton, the staged
1694
+ files (removable until Send) ride the files slot. */}
1695
+ <Composer
1696
+ onSend={(content) => {
1697
+ setComments((prev) => [...prev, { id: `cm_${(commentSeq += 1)}`, member_id: "mem_01", content, files: pendingFiles.length > 0 ? pendingFiles : undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]);
1698
+ setPendingFiles([]);
1699
+ }}
1700
+ placeholder="Write a comment…"
1701
+ sendLabel="Send"
1702
+ actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
1703
+ files={pendingFiles.length > 0 ? (
1704
+ <FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
1705
+ ) : undefined}
1706
+ />
1707
+ </Section>
1708
+ </View>
934
1709
 
935
- {/* collect band a headingless Subsection: the total owns the number */}
936
- <Subsection>
937
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
938
- <Text weight="semibold" style={{ flex: 1 }}>Total to collect</Text>
939
- <Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
940
- </View>
941
- {allMissing.length > 0 ? (
942
- <Callout tone="warning">
943
- <CalloutText>
944
- {allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
945
- </CalloutText>
946
- </Callout>
947
- ) : null}
948
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
949
- <Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
950
- <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
951
- </View>
952
- </Subsection>
953
-
954
- {/* deposit band — a named Subsection: labelled separate, never part
955
- of the total (the badge rides the heading row's right edge) */}
956
- <Subsection>
957
- <SubsectionHeading>
958
- <SubsectionHeadingTitle>Deposit</SubsectionHeadingTitle>
959
- <Badge label="Collected separately" color="zinc" />
960
- </SubsectionHeading>
961
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
962
- <Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
963
- <View style={{ flexGrow: 1, flexBasis: 160 }}>
964
- <InlineNumberInput
965
- value={deposit || null}
966
- onSave={persist((v: number | null) => setDeposit(v ?? 0))}
967
- min={0}
968
- format={money}
969
- placeholder="—"
970
- accessibilityLabel="Deposit amount"
971
- />
972
- </View>
973
- <Button
974
- title="Deposit receipt"
975
- color="secondary"
976
- disabled={deposit <= 0}
977
- onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
978
- />
979
- </View>
980
- </Subsection>
981
- </SubsectionStack>
982
- </Section>
983
- </View>
984
-
985
- {/* FILES — the record's document CRUD: drop to add, click a thumbnail
986
- to preview in the gallery, ✕ to delete. */}
987
- <View onLayout={nav.register("files")}>
988
- <Section>
989
- <SectionHeading>
990
- <SectionHeadingTitle description="The signed quote, the PO, photos — everything filed on the order.">Files</SectionHeadingTitle>
991
- <OwnerTag stage="operations" />
992
- {files.length > 0 ? <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta> : null}
993
- </SectionHeading>
994
- {files.length > 0 ? (
995
- <FileThumbnailGrid
996
- files={files}
997
- itemSize={84}
998
- onFilePress={(f) => setActiveFile(files.findIndex((x) => x.id === f.id))}
999
- onRemove={(fid) => setFiles((prev) => prev.filter((x) => x.id !== fid))}
1000
- />
1001
- ) : null}
1002
- <FileDropzone
1003
- height={100}
1004
- label="Drop the signed quote or PO"
1005
- hint="or click to browse · PDF, images"
1006
- dropLabel="Release to attach"
1007
- accept="application/pdf,image/*"
1008
- accessibilityLabel="Attach documents"
1009
- onFiles={(dropped) =>
1010
- setFiles((prev) => [
1011
- ...prev,
1012
- ...dropped.map((f) => ({
1013
- id: `file_${(fileSeq.current += 1)}`,
1014
- filename: f.name,
1015
- mimeType: f.type || "application/octet-stream",
1016
- url: URL.createObjectURL(f),
1017
- })),
1018
- ])
1019
- }
1020
- />
1021
- </Section>
1022
- </View>
1023
-
1024
- {/* TASKS — the handoff checklist, grouped by the desk that owns
1025
- them; every row edits (the stage gates the handoff and Billing,
1026
- never task editing). The CURRENT desk's open tasks warn beside
1027
- the handoff CTA below. */}
1028
- <View onLayout={nav.register("tasks")}>
1029
- <Section>
1030
- <SectionHeading>
1031
- <SectionHeadingTitle description="Each desk clears its checklist to hand the record off.">Tasks</SectionHeadingTitle>
1032
- {/* the same compact meter as the register's Tasks column */}
1033
- {tasks.length > 0 ? (
1034
- <View style={{ width: 120 }}>
1035
- <ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
1036
- </View>
1710
+ {/* TASKSthe handoff checklist, grouped by the desk that owns
1711
+ them; every row edits (the stage gates the handoff and Billing,
1712
+ never task editing). The CURRENT desk's open tasks warn beside
1713
+ the handoff CTA below. */}
1714
+ <View onLayout={nav.register("tasks")}>
1715
+ <Section>
1716
+ <SectionHeading>
1717
+ <SectionHeadingTitle description="Each desk clears its checklist to hand the record off.">Tasks</SectionHeadingTitle>
1718
+ {/* the same compact meter as the register's Tasks column */}
1719
+ {tasks.length > 0 ? (
1720
+ <View style={{ width: 120 }}>
1721
+ <ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
1722
+ </View>
1037
1723
  ) : null}
1038
1724
  </SectionHeading>
1039
1725
  {/* the Task-list toolbar grammar: a clearable Group-by FilterChip +
@@ -1102,18 +1788,21 @@ export function TplRecord() {
1102
1788
  {/* the capture row at the TOP — a new task lands on the capture desk
1103
1789
  (the current one; the last desk once closed) */}
1104
1790
  <CaptureRow value={newTask} onChangeText={setNewTask} onSubmit={addTask} placeholder={`Add a task for ${stageOf(captureDesk).label}…`} accessibilityLabel="Add a task" />
1105
- <View style={{ gap: 14 }}>
1791
+ {/* the groups divide via the SubsectionStack beat (space-only) —
1792
+ the head is a SubsectionHeading carrying the group's name + count,
1793
+ the "all" (ungrouped) view a headingless Subsection */}
1794
+ <SubsectionStack>
1106
1795
  {taskSections.map((g) => {
1107
1796
  const desk = g.desk;
1108
1797
  return (
1109
- <View key={g.key} style={{ gap: 6 }}>
1798
+ <Subsection key={g.key}>
1110
1799
  {g.head ? (
1111
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1800
+ <SubsectionHeading>
1112
1801
  {g.head}
1113
1802
  {g.items.length > 0 ? (
1114
1803
  <Text size="xs" color="muted" tabular>{`${g.items.filter((t) => t.done).length}/${g.items.length}`}</Text>
1115
1804
  ) : null}
1116
- </View>
1805
+ </SubsectionHeading>
1117
1806
  ) : null}
1118
1807
  {/* rows ride the Checklist compound — one geometry, EVERY
1119
1808
  desk's rows fully editable (the stage gates the handoff
@@ -1175,74 +1864,653 @@ export function TplRecord() {
1175
1864
  </View>
1176
1865
  ) : null}
1177
1866
  </Checklist>
1178
- </View>
1867
+ </Subsection>
1179
1868
  );
1180
1869
  })}
1870
+ </SubsectionStack>
1871
+ </Section>
1872
+ </View>
1873
+
1874
+ {/* DOCUMENTS — the document desk: the register Table (search · Create
1875
+ documents · Add files) whose selection feeds the Use-AI fork below. */}
1876
+ <View onLayout={nav.register("documents")}>
1877
+ <Section>
1878
+ <SectionHeading>
1879
+ <SectionHeadingTitle>Documents</SectionHeadingTitle>
1880
+ </SectionHeading>
1881
+ {/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
1882
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1883
+ <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
1884
+ <SearchInput
1885
+ placeholder="Search documents…"
1886
+ value={docSearch}
1887
+ onChangeText={setDocSearch}
1888
+ accessibilityLabel="Search documents"
1889
+ />
1890
+ </View>
1891
+ <View style={{ flex: 1 }} />
1892
+ {/* no Create here — GENERATION lives in the Document set section
1893
+ below (this desk = intake); tpl_documents, whose page has no
1894
+ output section, keeps the dialog flavor in this slot */}
1895
+ <Button
1896
+ title="Add files"
1897
+ color="secondary"
1898
+ onPress={() => {
1899
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((chosen) => {
1900
+ if (chosen.length === 0) return;
1901
+ const added = chosen.map((f, i) => ({ id: `added-${f.name}-${i}`, name: f.name, mimeType: f.type || "application/pdf", kind: (f.type || "application/pdf").split("/")[1]?.toUpperCase().slice(0, 4) ?? "FILE", sizeKB: Math.round(f.size / 1024), added: "just now", addedAt: 999 }));
1902
+ // PENDING until the user chooses: saving is a decision the
1903
+ // dialog asks for (save only / run a task), never a side
1904
+ // effect of picking — closing the dialog discards.
1905
+ setPicked(added);
1906
+ setUploadFlow(true);
1907
+ setAiOpen(true);
1908
+ });
1909
+ }}
1910
+ />
1181
1911
  </View>
1912
+ <Table
1913
+ columns={DOC_COLUMNS}
1914
+ leading={24}
1915
+ trailing={44}
1916
+ sort={docSort}
1917
+ onSort={(key) => setDocSort(cycleSort(docSort, key))}
1918
+ selectAll={
1919
+ <CheckboxInput
1920
+ accessibilityLabel="Select all documents"
1921
+ checked={sel.allSelected(visibleFiles.map((f) => f.id))}
1922
+ indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
1923
+ onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
1924
+ />
1925
+ }
1926
+ >
1927
+ {visibleFiles.map((f, fi) => (
1928
+ <TableRow
1929
+ key={f.id}
1930
+ onPress={() => openPreview(visibleFiles, fi)}
1931
+ marked={sel.has(f.id)}
1932
+ accessibilityLabel={`Open ${f.name}`}
1933
+ leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
1934
+ trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
1935
+ >
1936
+ <TableCell>
1937
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1938
+ {/* ONE square 32px slot for every type: an image fills it as a
1939
+ real thumbnail, a document centers its badge in it */}
1940
+ <FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
1941
+ <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
1942
+ </View>
1943
+ </TableCell>
1944
+ <TableCell>
1945
+ <Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
1946
+ </TableCell>
1947
+ <TableCell>
1948
+ <Text size="sm" tabular>{f.added}</Text>
1949
+ </TableCell>
1950
+ </TableRow>
1951
+ ))}
1952
+ </Table>
1182
1953
  </Section>
1183
1954
  </View>
1184
1955
 
1185
- {/* THE HANDOFF GATE the atomic boundary between departments, not a
1186
- form and not a message: the record itself changes desks. Each stage
1187
- names what it owes (the why-hint) before its CTA un-gates; the
1188
- receiving department finds the record in ITS register. */}
1189
- {gate ? (
1190
- <View style={{ gap: 12 }}>
1191
- {remaining > 0 ? (
1192
- <Callout tone="info">
1193
- <CalloutText>{taskHint}</CalloutText>
1194
- </Callout>
1195
- ) : null}
1196
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8 }}>
1197
- {prevStage ? (
1198
- <Button title={`Back to ${stageOf(prevStage).label}`} color="muted" onPress={() => setStage(prevStage)} />
1956
+ {/* CUSTOMER TWO states, no edit mode. Attached: the READ-ONLY card
1957
+ (the customer is a linked record, not this record's fields); the
1958
+ correction path is Remove attach the right one (the create
1959
+ Dialog carries tax ID + the registry Fetch). Empty: the
1960
+ find-or-create search; the custom row opens the create Dialog. */}
1961
+ <View onLayout={nav.register("customer")}>
1962
+ <Section>
1963
+ <SectionHeading>
1964
+ <SectionHeadingTitle description="The bill-to party — its tax ID gates invoicing below.">Customer</SectionHeadingTitle>
1965
+ </SectionHeading>
1966
+ {customer ? (
1967
+ <View style={{ gap: 10 }}>
1968
+ {/* the LINKED-record REFERENCE IMPLEMENTATION (no wrapper
1969
+ component; copy and customize): a BORDERED box scoping the
1970
+ other record's data — identity, then the facts stacked
1971
+ VERTICALLY (legible at any width, never squeezed), the verbs
1972
+ visible inside (destructive LEFT in danger, go-to RIGHT).
1973
+ The WHOLE box presses open the detail drawer — built on
1974
+ `PressableRow` (role-less surface, hover wash spans nested
1975
+ controls) + the `BoxDoor` sibling: NEVER role="button" on a
1976
+ container with interactive descendants (invalid HTML). */}
1977
+ <PressableRow
1978
+ onPress={() => setCustomerOpen(true)}
1979
+ style={{ flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingVertical: 16, gap: 12 }}
1980
+ >
1981
+ <BoxDoor label={`${customer.name} — details`} onPress={() => setCustomerOpen(true)} />
1982
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1983
+ <View style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
1984
+ <Icon name="building-2" size={17} color={colors.zinc[600]} />
1985
+ </View>
1986
+ <View style={{ flex: 1, minWidth: 0 }}>
1987
+ <Text size="sm" weight="medium" numberOfLines={1}>{customer.name}</Text>
1988
+ <Text size="xs" color="muted" tabular>{customer.code}</Text>
1989
+ </View>
1990
+ </View>
1991
+ <View style={{ gap: 6 }}>
1992
+ {[
1993
+ { label: "Tax ID", value: customer.taxId || "—" },
1994
+ { label: "Contact", value: customer.contact || "—" },
1995
+ { label: "City", value: customer.city || "—" },
1996
+ ].map((f) => (
1997
+ <View key={f.label} style={{ flexDirection: "row", gap: 10 }}>
1998
+ <Text size="sm" color="muted" style={{ width: 80 }}>{f.label}</Text>
1999
+ <Text size="sm" style={{ flex: 1 }}>{f.value}</Text>
2000
+ </View>
2001
+ ))}
2002
+ </View>
2003
+ {/* the action row, INSIDE the box: destructive on the LEFT,
2004
+ the go-to verb on the RIGHT (the box itself = details);
2005
+ zIndex lifts the verbs above the door's hit area */}
2006
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 }}>
2007
+ <Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
2008
+ <View style={{ flex: 1 }} />
2009
+ <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
2010
+ </View>
2011
+ </PressableRow>
2012
+ {/* invalid STATE is a Callout CO-LOCATED with what it describes —
2013
+ it lives on the customer card, not floating at a section top */}
2014
+ {!taxIdValid ? (
2015
+ <Callout tone="warning">
2016
+ <CalloutText>No valid tax ID — invoicing stays blocked. Replace this customer with one that carries it.</CalloutText>
2017
+ </Callout>
1199
2018
  ) : null}
1200
- <Text size="xs" color="muted" align="right" style={{ flexGrow: 1, flexBasis: 160 }}>
1201
- {`Handing off moves the record to ${stageOf(gate.next).label} — fields stay editable after.`}
2019
+ </View>
2020
+ ) : (
2021
+ <View style={{ gap: 10 }}>
2022
+ <Combobox
2023
+ options={customerOptions}
2024
+ onValueChange={onPickCustomer}
2025
+ getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
2026
+ reflectSelection={false}
2027
+ allowCustom
2028
+ customOptionPlacement="top"
2029
+ customOptionLabel={(q) => `Create new customer “${q}”`}
2030
+ >
2031
+ <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
2032
+ <ComboboxContent emptyText="No customer matches — type a name to create one" />
2033
+ </Combobox>
2034
+ <Text size="xs" color="muted">
2035
+ No match? Pick “Create new customer …” to add one without leaving the order.
1202
2036
  </Text>
1203
- <Button title={gate.cta} color="primary" onPress={() => setStage(gate.next)} />
1204
2037
  </View>
1205
- </View>
1206
- ) : (
1207
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8 }}>
1208
- <Text size="xs" color="muted" style={{ flexGrow: 1, flexBasis: 160 }}>
1209
- Closed on 04/07/2026 every field still edits in place and saves on its own.
1210
- </Text>
1211
- <Button title="Reopen at Accounting" color="muted" onPress={() => setStage("accounting")} />
1212
- </View>
1213
- )}
2038
+ )}
2039
+ </Section>
2040
+ </View>
2041
+
2042
+ {/* FEESthe DETAILED money ledger, both directions (charge = billed
2043
+ to the customer · cost = paid to a vendor), distinct from Billing's
2044
+ invoice DOCUMENTS. The drill-down law: a compact register row, and
2045
+ EVERY row opens its FULL detail in a right-docked entity drawer
2046
+ (◀ ▶ steps the rows). Add is create-then-refine — a blank fee
2047
+ opens in the drawer. */}
2048
+ <View onLayout={nav.register("fees")}>
2049
+ <Section>
2050
+ <SectionHeading>
2051
+ <SectionHeadingTitle description="Every fee the order incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
2052
+ <Button title="Add fee" color="secondary" onPress={addFee} />
2053
+ </SectionHeading>
2054
+ <SummaryLine
2055
+ items={[
2056
+ { label: "collected", value: fees.filter((f) => f.direction === "charge" && f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true },
2057
+ { label: "to collect", value: fees.filter((f) => f.direction === "charge" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true },
2058
+ { 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 },
2059
+ ]}
2060
+ />
2061
+ {fees.length === 0 ? (
2062
+ <EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it opens ready to fill in." action={<Button title="Add fee" color="primary" onPress={addFee} />} />
2063
+ ) : (
2064
+ <Table columns={FEE_COLUMNS}>
2065
+ {fees.map((f) => {
2066
+ const st = feeStatus(f);
2067
+ return (
2068
+ <TableRow key={f.id} onPress={() => setFeeView({ kind: "edit", id: f.id })} selected={feeView?.id === f.id} accessibilityLabel={`Open ${f.name || "fee"}`}>
2069
+ <TableCell><Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text></TableCell>
2070
+ <TableCell><Text size="sm" color="muted">{f.direction === "charge" ? "Charge" : "Cost"}</Text></TableCell>
2071
+ <TableCell><Text size="sm" color="muted" numberOfLines={1}>{f.party || "—"}</Text></TableCell>
2072
+ <TableCell><Text size="sm" tabular numberOfLines={1}>{formatMoney(f.amount)}</Text></TableCell>
2073
+ <TableCell><Text size="sm" color={st.danger ? "danger" : "muted"} tabular numberOfLines={1}>{st.text}</Text></TableCell>
2074
+ </TableRow>
2075
+ );
2076
+ })}
2077
+ </Table>
2078
+ )}
2079
+ </Section>
2080
+ </View>
1214
2081
 
1215
- {/* the settings shape: title + description left, a live control right,
1216
- applied on change */}
1217
- <View onLayout={nav.register("preferences")}>
2082
+ {/* BILLING invoice documents on the record, in the inline vocabulary.
2083
+ The issue action lives in each band's header; ONE section-level
2084
+ callout explains what blocks issuing (no per-band totals, no
2085
+ per-band reasons — the collect band owns the number). */}
2086
+ <View onLayout={nav.register("billing")}>
1218
2087
  <Section>
1219
2088
  <SectionHeading>
1220
- <SectionHeadingTitle description="Per-order handling and notification preferences.">Preferences</SectionHeadingTitle>
2089
+ <SectionHeadingTitle description="Each invoice owns its charge lines — a charge never lives apart from the document it bills on.">Billing</SectionHeadingTitle>
1221
2090
  </SectionHeading>
1222
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
1223
- <View style={{ flex: 1, gap: 1 }}>
1224
- <Text size="sm">Notify the customer</Text>
1225
- <Text size="xs" color="muted">Email changes to the primary contact</Text>
2091
+
2092
+ {/* The action-gating law: a not-ready CTA is DISABLED and a PROBLEM
2093
+ reads as a co-located Callout at its scope, once — a band-local
2094
+ inconsistency in its band, a broken record-level premise here.
2095
+ The stage gate is NOT a problem (just not yet) — it stays
2096
+ silent. Never prose beside a CTA. */}
2097
+ {customer === null ? (
2098
+ <Callout tone="warning">
2099
+ <CalloutText>Attach a customer — invoices need a bill-to party.</CalloutText>
2100
+ </Callout>
2101
+ ) : !taxIdValid ? (
2102
+ <Callout tone="warning">
2103
+ <CalloutText>The customer has no valid tax ID — replace it in the Customer section.</CalloutText>
2104
+ </Callout>
2105
+ ) : null}
2106
+
2107
+ {/* the section's GROUPS — bands · collect · deposit — divide via the
2108
+ SubsectionStack beat, no hand-rolled gaps */}
2109
+ <SubsectionStack>
2110
+ {invoices.map((inv) => (
2111
+ <InvoiceBand
2112
+ key={inv.key}
2113
+ inv={inv}
2114
+ /* issuing gates on the RECORD's premises only (a customer with a
2115
+ valid tax ID) — never silently on the stage: a filled band
2116
+ whose Issue stays dead with no visible reason is a trap */
2117
+ gated={customer === null || !taxIdValid}
2118
+ onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
2119
+ onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
2120
+ onIssue={setConfirmIssue}
2121
+ />
2122
+ ))}
2123
+
2124
+ {/* collect band — a headingless Subsection: the total owns the number */}
2125
+ <Subsection>
2126
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
2127
+ <Text weight="semibold" style={{ flex: 1 }}>Total to collect</Text>
2128
+ <Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
1226
2129
  </View>
1227
- <Switch accessibilityLabel="Notify the customer" value={notifyCustomer} onChange={setNotifyCustomer} />
1228
- </View>
1229
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
1230
- <View style={{ flex: 1, gap: 1 }}>
1231
- <Text size="sm">Allow partial delivery</Text>
1232
- <Text size="xs" color="muted">Ship available lines before the rest arrive</Text>
2130
+ {allMissing.length > 0 ? (
2131
+ <Callout tone="warning">
2132
+ <CalloutText>
2133
+ {allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
2134
+ </CalloutText>
2135
+ </Callout>
2136
+ ) : null}
2137
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
2138
+ <Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
2139
+ <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
1233
2140
  </View>
1234
- <Switch accessibilityLabel="Allow partial delivery" value={partialDelivery} onChange={setPartialDelivery} />
1235
- </View>
2141
+ </Subsection>
2142
+
2143
+ {/* deposit band — a named Subsection, never part of the total */}
2144
+ <Subsection>
2145
+ <SubsectionHeading>
2146
+ <SubsectionHeadingTitle>Deposit</SubsectionHeadingTitle>
2147
+ </SubsectionHeading>
2148
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
2149
+ <Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
2150
+ <View style={{ flexGrow: 1, flexBasis: 160 }}>
2151
+ <InlineNumberInput
2152
+ value={deposit || null}
2153
+ onSave={persist((v: number | null) => setDeposit(v ?? 0))}
2154
+ min={0}
2155
+ format={money}
2156
+ placeholder="—"
2157
+ accessibilityLabel="Deposit amount"
2158
+ />
2159
+ </View>
2160
+ <Button
2161
+ title="Deposit receipt"
2162
+ color="secondary"
2163
+ disabled={deposit <= 0}
2164
+ onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
2165
+ />
2166
+ </View>
2167
+ </Subsection>
2168
+ </SubsectionStack>
2169
+ </Section>
2170
+ </View>
2171
+
2172
+ {/* DOCUMENT SET — the OUTPUT desk, the last WORK section: the record's
2173
+ top is intake, the bottom produces the paperwork on demand. Forms
2174
+ group PER PARTY (each party gets its set); the common ones ship
2175
+ pre-checked and the long tail folds behind "Show all forms".
2176
+ Between the pickers and the CTA sit the OUTPUT CONFIG fields —
2177
+ values printed on the forms, each with the input its shape wants.
2178
+ Create regenerates the whole set (derived paperwork — the desk
2179
+ register above keeps what ARRIVED); the produced files list right
2180
+ under the action row, previewable, with Download all beside. */}
2181
+ <View onLayout={nav.register("docset")}>
2182
+ <Section>
2183
+ <SectionHeading>
2184
+ <SectionHeadingTitle description="The paperwork this record produces — pick each party's forms, generate on demand.">Document set</SectionHeadingTitle>
2185
+ </SectionHeading>
2186
+ <SubsectionStack>
2187
+ {FORM_GROUPS.map((g) => {
2188
+ const groupForms = g.forms.filter(isEligible);
2189
+ const open = unfolded.has(g.id);
2190
+ const shown = open ? groupForms : groupForms.slice(0, g.visible);
2191
+ const hidden = groupForms.length - g.visible;
2192
+ return (
2193
+ <Subsection key={g.id}>
2194
+ <SubsectionHeading>
2195
+ <SubsectionHeadingTitle>{g.party(customer?.name ?? null)}</SubsectionHeadingTitle>
2196
+ </SubsectionHeading>
2197
+ {/* the PICKER rows ride the Checklist primitive — IT owns
2198
+ the geometry (row height, control/title alignment, the
2199
+ meta/expansion indent); hand-rolling this anatomy is how
2200
+ alignment drifts. controlWidth 24 = CheckboxInput. */}
2201
+ <Checklist controlWidth={24}>
2202
+ {shown.map((f) => {
2203
+ // READINESS per row, DISCOVERABLE: every not-ready row
2204
+ // shows its mark (what's missing — warning ink once
2205
+ // checked, because it now blocks) AND the explicit
2206
+ // trigger beside the label — a colored TextLink (an
2207
+ // inline row action; a Button here outweighs the row)
2208
+ // toggling the TRANSIENT fill editor in the row's
2209
+ // `expansion`. Save commits onto the record fields
2210
+ // (their home rows show the values) and the panel
2211
+ // collapses resolved; mark + trigger vanish = ready. A
2212
+ // ready form stays SILENT. Checking never expands
2213
+ // anything by itself.
2214
+ const missing = missingOf(f);
2215
+ const checked = chosenForms.has(f.id);
2216
+ const filling = missing.length > 0 && openFill.has(f.id);
2217
+ return (
2218
+ <ChecklistRow
2219
+ key={f.id}
2220
+ control={<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />}
2221
+ meta={missing.length > 0 && !filling ? (
2222
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}>
2223
+ <Icon name="circle-alert" size={12} color={colors.amber[500]} />
2224
+ <Text size="xs" color={checked ? "warning" : "muted"}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
2225
+ </View>
2226
+ ) : undefined}
2227
+ expansion={filling ? (
2228
+ <View style={{ backgroundColor: colors.zinc[50], borderRadius: 10, padding: 14, gap: 12, maxWidth: 460 }}>
2229
+ <Text size="xs" color="muted">These values save onto the order and unlock the form.</Text>
2230
+ {missing.map((k) => (
2231
+ <FormTextInput
2232
+ key={k}
2233
+ label={NEEDS[k].label}
2234
+ description={NEEDS[k].hint}
2235
+ value={fillDrafts[k] ?? ""}
2236
+ onChangeText={(t) => setFillDrafts((d) => ({ ...d, [k]: t }))}
2237
+ accessibilityLabel={NEEDS[k].label}
2238
+ />
2239
+ ))}
2240
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
2241
+ {/* secondary — the section keeps ONE primary
2242
+ (Create); disabled while a draft is empty */}
2243
+ <Button title="Cancel" color="muted" onPress={() => toggleFillOpen(f.id)} />
2244
+ <Button
2245
+ title="Save fields"
2246
+ color="secondary"
2247
+ disabled={missing.some((k) => !(fillDrafts[k] ?? "").trim())}
2248
+ onPress={() => { saveNeeds(missing); toggleFillOpen(f.id); }}
2249
+ />
2250
+ </View>
2251
+ </View>
2252
+ ) : undefined}
2253
+ >
2254
+ <View style={{ flexDirection: "row", alignItems: "center", columnGap: 12, rowGap: 2, flexWrap: "wrap" }}>
2255
+ <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2256
+ {/* the trigger hides while ITS panel is open —
2257
+ the panel's Cancel is the closer; a toggle
2258
+ verb above an open editor is noise */}
2259
+ {missing.length > 0 && !filling ? (
2260
+ <TextLink size="xs" onPress={() => toggleFillOpen(f.id)} style={{ color: colors.blue[600] }}>
2261
+ Add missing fields
2262
+ </TextLink>
2263
+ ) : null}
2264
+ </View>
2265
+ </ChecklistRow>
2266
+ );
2267
+ })}
2268
+ </Checklist>
2269
+ {hidden > 0 ? (
2270
+ <View style={{ flexDirection: "row" }}>
2271
+ <Button
2272
+ title={open ? "Show fewer forms" : `Show all forms (${hidden} hidden)`}
2273
+ color="muted"
2274
+ onPress={() => toggleFold(g.id)}
2275
+ />
2276
+ </View>
2277
+ ) : null}
2278
+ </Subsection>
2279
+ );
2280
+ })}
2281
+ {/* the output config — values printed on every generated form */}
2282
+ <Subsection>
2283
+ <DetailTable labelWidth={150} trailingWidth={88}>
2284
+ <DetailRow label="Issuing office" description="Printed in every form's header">
2285
+ <InlineTextInput value={issuingOffice} onSave={persist(setIssuingOffice)} placeholder="Add the office…" accessibilityLabel="Issuing office" />
2286
+ </DetailRow>
2287
+ <DetailRow label="Prefill signing date">
2288
+ <CheckboxInput accessibilityLabel="Prefill signing date" checked={prefillSignDate} onChange={setPrefillSignDate} />
2289
+ </DetailRow>
2290
+ </DetailTable>
2291
+ </Subsection>
2292
+ <Subsection>
2293
+ <View style={{ gap: 12 }}>
2294
+ {/* the CONSEQUENCE, co-located, once (the gating law's
2295
+ degraded-but-valid case — Create stays ENABLED and the
2296
+ press confirms; the repair lives in the rows' fill
2297
+ panels above). An empty pick stays silently disabled —
2298
+ self-evident. */}
2299
+ {blockingNeeds.length > 0 ? (
2300
+ <Callout tone="warning">
2301
+ <CalloutText>
2302
+ {`The checked forms still need ${blockingNeeds.map((k) => NEEDS[k].label.toLowerCase()).join(" and ")} — ${blockingNeeds.length === 1 ? "it prints" : "they print"} blank unless filled above.`}
2303
+ </CalloutText>
2304
+ </Callout>
2305
+ ) : null}
2306
+ {/* deterministic work = a LOADING state on its trigger, never
2307
+ AgentRun theater (that's for AI); the set appears at once */}
2308
+ <View style={{ flexDirection: "row" }}>
2309
+ <Button
2310
+ title={docSet.length > 0 ? "Create document set again" : "Create document set"}
2311
+ color="primary"
2312
+ disabled={checkedForms.length === 0}
2313
+ loading={producing}
2314
+ onPress={createSet}
2315
+ />
2316
+ </View>
2317
+ {docSet.length > 0 ? (
2318
+ <View style={{ gap: 4 }}>
2319
+ {docSet.map((d, di) => (
2320
+ <FileRow
2321
+ key={d.id}
2322
+ name={d.name}
2323
+ meta={`${d.kind} · ${fmtSize(d.sizeKB)}`}
2324
+ mimeType={d.mimeType}
2325
+ onPress={() => openPreview(docSet, di)}
2326
+ trailing={
2327
+ <ActionMenu
2328
+ accessibilityLabel={`Actions for ${d.name}`}
2329
+ items={[
2330
+ { key: "download", label: "Download", icon: "download", onPress: () => { /* a real app: openExternal(d.url) */ } },
2331
+ { key: "remove", label: "Remove", icon: "trash", danger: true, onPress: () => removeFromSet(d.id) },
2332
+ ]}
2333
+ />
2334
+ }
2335
+ />
2336
+ ))}
2337
+ {/* the whole-set verb sits BELOW the files it acts on */}
2338
+ <View style={{ flexDirection: "row", marginTop: 8 }}>
2339
+ <Button title="Download all" color="secondary" icon="download" onPress={() => { /* a real app zips or walks openExternal per file */ }} />
2340
+ </View>
2341
+ </View>
2342
+ ) : null}
2343
+ </View>
2344
+ </Subsection>
2345
+ </SubsectionStack>
2346
+ </Section>
2347
+ </View>
2348
+
2349
+ {/* DELIVERY RECEIPT — the QUICK-ISSUE form: capture a moment's facts
2350
+ (a handover, an inspection, a visit), issue ONE document. Use it
2351
+ when one document is issued from a handful of facts at a known
2352
+ moment — vs the Document set's pick-from-registries.
2353
+ THE FORM-ACTION ALIGNMENT LAW: on an open page a label-left
2354
+ form's action row rides the form's OWN GRID — an empty-label
2355
+ DetailRow puts the CTA (and the produced file) exactly on the
2356
+ CONTROL COLUMN, the same left edge the user just filled, and
2357
+ inherits stacked mode on narrow containers. A right-floated
2358
+ button aligns to nothing (overlay FOOTERS right-align —
2359
+ Dialog/Drawer; in-page forms never). */}
2360
+ <View onLayout={nav.register("receipt")}>
2361
+ <Section>
2362
+ <SectionHeading>
2363
+ <SectionHeadingTitle description="Issued on handover — record the delivery facts, generate the receipt.">Delivery receipt</SectionHeadingTitle>
2364
+ </SectionHeading>
2365
+ <DetailTable labelWidth={150} trailingWidth={88}>
2366
+ <DetailRow label="Received">
2367
+ <InlineDatePicker value={rcptDate} onSave={persist(setRcptDate)} locale="en-US" accessibilityLabel="Received date" />
2368
+ </DetailRow>
2369
+ {/* a FLAT value row — `flat` tucks the annotation under the TEXT
2370
+ (the control band's slack must not read as a hole) */}
2371
+ <DetailRow label="Receipt no" description="Issued automatically" flat>
2372
+ <InlineStatic value="RCPT-2026-0715" tabular />
2373
+ </DetailRow>
2374
+ <DetailRow label="Handled by">
2375
+ <InlineMemberSelect members={TEAM} value={rcptHandler} onSave={persist(setRcptHandler)} placeholder="Assign…" accessibilityLabel="Handled by" />
2376
+ </DetailRow>
2377
+ <DetailRow label="Reference" description="Prefilled from the order">
2378
+ <InlineTextInput value={rcptReference} onSave={persist(setRcptReference)} accessibilityLabel="Reference" />
2379
+ </DetailRow>
2380
+ <DetailRow label="Condition">
2381
+ <InlineSelect value={rcptCondition} options={[{ value: "good", label: "Good" }, { value: "partial", label: "Partial damage" }, { value: "damaged", label: "Damaged" }]} onSave={persist(setRcptCondition)} accessibilityLabel="Condition" />
2382
+ </DetailRow>
2383
+ <DetailRow label="Note">
2384
+ <InlineTextInput value={rcptNote} onSave={persist(setRcptNote)} placeholder="Add a note…" accessibilityLabel="Receipt note" />
2385
+ </DetailRow>
2386
+ <DetailRow label="">
2387
+ <View style={{ gap: 8 }}>
2388
+ <View style={{ flexDirection: "row" }}>
2389
+ <Button
2390
+ title={rcptDoc ? "Create delivery receipt again" : "Create delivery receipt"}
2391
+ color="primary"
2392
+ loading={rcptProducing}
2393
+ onPress={createReceipt}
2394
+ />
2395
+ </View>
2396
+ {rcptDoc ? (
2397
+ <FileRow
2398
+ name={rcptDoc.name}
2399
+ meta={`${rcptDoc.kind} · ${fmtSize(rcptDoc.sizeKB)}`}
2400
+ mimeType={rcptDoc.mimeType}
2401
+ onPress={() => openPreview([rcptDoc], 0)}
2402
+ trailing={
2403
+ <ActionMenu
2404
+ accessibilityLabel={`Actions for ${rcptDoc.name}`}
2405
+ items={[
2406
+ { key: "download", label: "Download", icon: "download", onPress: () => { /* a real app: openExternal */ } },
2407
+ { key: "remove", label: "Remove", icon: "trash", danger: true, onPress: () => setRcptDoc(null) },
2408
+ ]}
2409
+ />
2410
+ }
2411
+ />
2412
+ ) : null}
2413
+ </View>
2414
+ </DetailRow>
2415
+ </DetailTable>
1236
2416
  </Section>
1237
2417
  </View>
1238
2418
 
1239
- <DangerZone
1240
- title="Delete record"
1241
- description="Permanently remove this record and its invoices, charges, and history. This can't be undone."
1242
- >
1243
- <Button title="Delete record" color="danger" onPress={() => {}} />
1244
- </DangerZone>
2419
+ {/* ACTIVITY — the audit trail: every change on the record, who and
2420
+ when. Seeded history + LIVE appends from this surface's real
2421
+ actions (issue, fees, handoff). */}
2422
+ <View onLayout={nav.register("activity")}>
2423
+ <Section>
2424
+ <SectionHeading>
2425
+ <SectionHeadingTitle description="Every change on the record — who made it, and when.">Activity</SectionHeadingTitle>
2426
+ </SectionHeading>
2427
+ <Timeline items={activity} />
2428
+ </Section>
2429
+ </View>
2430
+
2431
+ {/* HANDOFF — the reference mark of the boundary: handing off CREATES
2432
+ the next desk's record on ITS table and links it here. The trail
2433
+ marks whom · where · when + the linked sibling; the sibling owns
2434
+ its own summary. */}
2435
+ <View onLayout={nav.register("handoff")}>
2436
+ <Section>
2437
+ <SectionHeading>
2438
+ <SectionHeadingTitle description="Handing off creates the next desk's record and links it here for reference.">Handoff</SectionHeadingTitle>
2439
+ </SectionHeading>
2440
+ {/* the records the handoffs CREATED — the SAME linked-record
2441
+ composition as the Customer section (a bordered view + visible
2442
+ actions; copy and customize) */}
2443
+ {siblings.length > 0 ? (
2444
+ <View style={{ gap: 16 }}>
2445
+ {siblings.map((sb) => (
2446
+ <PressableRow
2447
+ key={sb.id}
2448
+ onPress={() => setSiblingOpen(sb.id)}
2449
+ style={{ flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingVertical: 16, gap: 12 }}
2450
+ >
2451
+ <BoxDoor label={`${sb.code} — details`} onPress={() => setSiblingOpen(sb.id)} />
2452
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
2453
+ <View style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
2454
+ <Icon name="file-text" size={17} color={colors.zinc[600]} />
2455
+ </View>
2456
+ <View style={{ flex: 1, minWidth: 0 }}>
2457
+ <Text size="sm" weight="medium" numberOfLines={1}>{sb.code}</Text>
2458
+ <Text size="xs" color="muted">{`${sb.desk} record`}</Text>
2459
+ </View>
2460
+ </View>
2461
+ <View style={{ gap: 6 }}>
2462
+ {[
2463
+ { label: "Assignee", value: sb.assignee || "—" },
2464
+ { label: "Created", value: sb.at },
2465
+ { label: "Note", value: sb.note || "—" },
2466
+ ].map((f) => (
2467
+ <View key={f.label} style={{ flexDirection: "row", gap: 10 }}>
2468
+ <Text size="sm" color="muted" style={{ width: 80 }}>{f.label}</Text>
2469
+ <Text size="sm" style={{ flex: 1 }}>{f.value}</Text>
2470
+ </View>
2471
+ ))}
2472
+ </View>
2473
+ {/* the action row, INSIDE the box, the go-to verb on the
2474
+ RIGHT (the box itself = details; nothing destructive
2475
+ here — the handoff mark stays); zIndex lifts it above
2476
+ the door's hit area */}
2477
+ <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, justifyContent: "flex-end", zIndex: 1 }}>
2478
+ <Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
2479
+ </View>
2480
+ </PressableRow>
2481
+ ))}
2482
+ </View>
2483
+ ) : null}
2484
+ {handoffs.length > 0 ? <Timeline items={handoffs} /> : null}
2485
+ {/* the handoff happens ONCE — before it, the single CTA; after it,
2486
+ the mark + the linked record ARE the section, no further CTAs */}
2487
+ {siblings.length === 0 && gate ? (
2488
+ <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
2489
+ <Button title={gate.cta} color="primary" onPress={() => setHandoffOpen(true)} />
2490
+ </View>
2491
+ ) : null}
2492
+ </Section>
2493
+ </View>
2494
+
2495
+ {/* the rail lists EVERY section — the danger zone included, with the
2496
+ SAME section anatomy (heading + description) as everything else;
2497
+ the fenced red card is the section's BODY, not its heading */}
2498
+ <View onLayout={nav.register("danger")}>
2499
+ <Section>
2500
+ <SectionHeading>
2501
+ <SectionHeadingTitle description="Destructive, irreversible operations — deliberately fenced at the end of the page.">Danger zone</SectionHeadingTitle>
2502
+ </SectionHeading>
2503
+ <DangerZone
2504
+ title="Delete record"
2505
+ description="Permanently remove this record and its invoices, charges, and history. This can't be undone."
2506
+ >
2507
+ <Button title="Delete record" color="danger" onPress={() => {}} />
2508
+ </DangerZone>
2509
+ </Section>
2510
+ </View>
1245
2511
  </SectionStack>
2512
+ </>
2513
+ )}
1246
2514
 
1247
2515
  {/* the narrow-mode section picker — a full-page takeover: pick a section
1248
2516
  to jump there; Escape or the close control dismisses */}
@@ -1267,14 +2535,14 @@ export function TplRecord() {
1267
2535
  </Modal>
1268
2536
 
1269
2537
  {/* find-or-create's CREATE branch — a focused Dialog (4 fields is
1270
- dialog-weight; an inline swap shifts the page). Essentials only:
1271
- after Create & attach, the card's Fetch button fills the rest. */}
2538
+ dialog-weight; an inline swap shifts the page). Essentials only: the
2539
+ dialog's Fetch fills contact + city off the tax ID. */}
1272
2540
  <Dialog width={520} open={custDraft !== null} onOpenChange={(o) => { if (!o) setCustDraft(null); }}>
1273
2541
  <DialogHeader>
1274
2542
  <DialogHeaderTitle>New customer</DialogHeaderTitle>
1275
2543
  </DialogHeader>
1276
- {/* the SAME inline-chip table as the edit card create and edit are
1277
- one vocabulary, only the commit differs (Create & attach) */}
2544
+ {/* the same inline-chip table vocabulary as the record's field grids
2545
+ only the commit differs (Create & attach) */}
1278
2546
  <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 8 }}>
1279
2547
  <DetailTable labelWidth={150} trailingWidth={88}>
1280
2548
  <DetailRow label="Customer name">
@@ -1288,8 +2556,11 @@ export function TplRecord() {
1288
2556
  accessibilityLabel="Customer name"
1289
2557
  />
1290
2558
  </DetailRow>
2559
+ {/* the field-level failure rides the ROW (`DetailRow error` —
2560
+ FormField's alert semantics), not a loose line below the table */}
1291
2561
  <DetailRow
1292
2562
  label="Tax ID"
2563
+ error={taxIdError}
1293
2564
  trailing={
1294
2565
  <Button
1295
2566
  title="Fetch"
@@ -1333,8 +2604,6 @@ export function TplRecord() {
1333
2604
  />
1334
2605
  </DetailRow>
1335
2606
  </DetailTable>
1336
- {taxIdError ? <Text size="xs" color="danger">{taxIdError}</Text> : null}
1337
- <Text size="xs" color="muted">Created and attached to this order — Fetch fills contact + city from the tax-ID registry.</Text>
1338
2607
  </View>
1339
2608
  <DialogFooter>
1340
2609
  <Button title="Cancel" color="secondary" onPress={() => setCustDraft(null)} />
@@ -1342,8 +2611,6 @@ export function TplRecord() {
1342
2611
  </DialogFooter>
1343
2612
  </Dialog>
1344
2613
 
1345
- <FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} />
1346
-
1347
2614
  {/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
1348
2615
  <Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssue(null); }}>
1349
2616
  <DialogHeader>
@@ -1365,17 +2632,409 @@ export function TplRecord() {
1365
2632
  <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
1366
2633
  </DialogFooter>
1367
2634
  </Dialog>
2635
+
2636
+ {/* the fee ENTITY DRAWER — the drill-down law: a register row opens its
2637
+ full detail right-docked; ◀ ▶ + the position caption step the rows
2638
+ without closing. Every field refines inline; Remove is confirmed. */}
2639
+ <Drawer
2640
+ open={openFee !== null}
2641
+ onOpenChange={(o) => { if (!o) setFeeView(null); }}
2642
+ title={openFee?.name || "New fee"}
2643
+ width={480}
2644
+ onPrev={feeIdx > 0 ? () => setFeeView({ kind: "edit", id: fees[feeIdx - 1].id }) : undefined}
2645
+ onNext={feeIdx >= 0 && feeIdx < fees.length - 1 ? () => setFeeView({ kind: "edit", id: fees[feeIdx + 1].id }) : undefined}
2646
+ position={feeIdx >= 0 ? `${feeIdx + 1}/${fees.length}` : undefined}
2647
+ >
2648
+ {openFee ? (
2649
+ <View style={{ padding: 20, gap: 8 }}>
2650
+ <DetailTable labelWidth={130}>
2651
+ <DetailRow label="Fee">
2652
+ <InlineTextInput value={openFee.name} onSave={persist((v: string) => patchFee(openFee.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
2653
+ </DetailRow>
2654
+ <DetailRow label="Type" description="Charge — billed to the customer · Cost — paid to a vendor">
2655
+ <InlineSelect
2656
+ value={openFee.direction}
2657
+ options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
2658
+ onSave={persist((v: FeeDirection) => patchFee(openFee.id, { direction: v }))}
2659
+ accessibilityLabel="Fee type"
2660
+ />
2661
+ </DetailRow>
2662
+ <DetailRow label="Party">
2663
+ <InlineTextInput value={openFee.party} onSave={persist((v: string) => patchFee(openFee.id, { party: v }))} placeholder="Who pays, or is paid…" accessibilityLabel="Party" />
2664
+ </DetailRow>
2665
+ <DetailRow label="Amount">
2666
+ <InlineNumberInput value={openFee.amount || null} onSave={persist((v: number | null) => patchFee(openFee.id, { amount: v ?? 0 }))} min={0} format={money} placeholder="—" accessibilityLabel="Amount" />
2667
+ </DetailRow>
2668
+ <DetailRow label="VAT (%)">
2669
+ <InlineNumberInput value={openFee.vat} onSave={persist((v: number | null) => patchFee(openFee.id, { vat: v }))} min={0} placeholder="—" accessibilityLabel="VAT percent" />
2670
+ </DetailRow>
2671
+ <DetailRow label="Due" error={feeOverdue(openFee) ? "Overdue — chase the payment or move the date." : undefined}>
2672
+ <InlineDatePicker value={openFee.due} onSave={persist((v: string) => patchFee(openFee.id, { due: v }))} locale="en-US" placeholder="Set a due date…" accessibilityLabel="Fee due date" />
2673
+ </DetailRow>
2674
+ <DetailRow label="Invoice no" description="The party's invoice or debit note">
2675
+ <InlineTextInput value={openFee.invoiceNo} onSave={persist((v: string) => patchFee(openFee.id, { invoiceNo: v }))} placeholder="Add the reference…" accessibilityLabel="Invoice number" />
2676
+ </DetailRow>
2677
+ <DetailRow label="Status">
2678
+ <InlineSelect
2679
+ value={openFee.paid ? "paid" : "unpaid"}
2680
+ options={[{ value: "unpaid", label: "Unpaid" }, { value: "paid", label: "Paid" }]}
2681
+ onSave={persist((v: string) => patchFee(openFee.id, { paid: v === "paid" }))}
2682
+ accessibilityLabel="Paid status"
2683
+ />
2684
+ </DetailRow>
2685
+ <DetailRow label="Note">
2686
+ <InlineTextInput value={openFee.note} onSave={persist((v: string) => patchFee(openFee.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
2687
+ </DetailRow>
2688
+ </DetailTable>
2689
+ </View>
2690
+ ) : (
2691
+ <View />
2692
+ )}
2693
+ {openFee ? (
2694
+ <DrawerFooter>
2695
+ <Button title="Remove fee" color="danger-secondary" icon="trash" onPress={() => deleteFee(openFee)} />
2696
+ </DrawerFooter>
2697
+ ) : null}
2698
+ </Drawer>
2699
+
2700
+ {/* the CUSTOMER detail drawer — the linked record's full info, read-only
2701
+ (it's ANOTHER record; editing happens on its own page). The footer
2702
+ carries the record-level verbs: navigate there, or detach. */}
2703
+ <Drawer open={customerOpen && customer !== null} onOpenChange={(o) => { if (!o) setCustomerOpen(false); }} title={customer?.name ?? ""} width={440}>
2704
+ {customer ? (
2705
+ <View style={{ padding: 20 }}>
2706
+ <DetailTable labelWidth={110}>
2707
+ <DetailRow label="Name"><InlineStatic value={customer.name} /></DetailRow>
2708
+ <DetailRow label="Code"><InlineStatic value={customer.code} tabular /></DetailRow>
2709
+ <DetailRow label="Tax ID"><InlineStatic value={customer.taxId || "—"} tabular /></DetailRow>
2710
+ <DetailRow label="Contact"><InlineStatic value={customer.contact || "—"} /></DetailRow>
2711
+ <DetailRow label="City"><InlineStatic value={customer.city || "—"} /></DetailRow>
2712
+ </DetailTable>
2713
+ </View>
2714
+ ) : (
2715
+ <View />
2716
+ )}
2717
+ <DrawerFooter>
2718
+ <Button title="Remove from record" color="danger-secondary" icon="x" onPress={() => { setCustomerOpen(false); setCustomerId(null); }} />
2719
+ {/* a real app navigates to the customer's own record page */}
2720
+ <Button title="Open record" color="secondary" onPress={() => {}} />
2721
+ </DrawerFooter>
2722
+ </Drawer>
2723
+
2724
+ {/* the SIBLING record's drawer — the handoff-created record's reference
2725
+ info + the navigate verb (a real app routes to its page). */}
2726
+ <Drawer open={openSibling !== null} onOpenChange={(o) => { if (!o) setSiblingOpen(null); }} title={openSibling?.code ?? ""} width={440}>
2727
+ {openSibling ? (
2728
+ <View style={{ padding: 20 }}>
2729
+ <DetailTable labelWidth={110}>
2730
+ <DetailRow label="Desk"><InlineStatic value={openSibling.desk} /></DetailRow>
2731
+ <DetailRow label="Assignee"><InlineStatic value={openSibling.assignee || "—"} /></DetailRow>
2732
+ <DetailRow label="Created"><InlineStatic value={openSibling.at} tabular /></DetailRow>
2733
+ <DetailRow label="From"><InlineStatic value={code} tabular /></DetailRow>
2734
+ <DetailRow label="Handoff note"><InlineStatic value={openSibling.note || "—"} /></DetailRow>
2735
+ </DetailTable>
2736
+ </View>
2737
+ ) : (
2738
+ <View />
2739
+ )}
2740
+ <DrawerFooter>
2741
+ {/* recall = the semi-destructive undo — behind the details, confirmed */}
2742
+ {openSibling ? <Button title="Recall handoff" color="danger-secondary" onPress={() => recallHandoff(openSibling)} /> : null}
2743
+ {/* a real app navigates to the sibling record's page */}
2744
+ <Button title="Open record" color="secondary" onPress={() => {}} />
2745
+ </DrawerFooter>
2746
+ </Drawer>
2747
+
2748
+ {/* the handoff dialog — who receives the record + a note; the rows name
2749
+ exactly WHAT is being handed off. Confirm writes the trail entry. */}
2750
+ <Dialog width={480} open={handoffOpen} onOpenChange={(o) => { if (!o) { setHandoffOpen(false); setHandoffAssignee(null); setHandoffNote(""); } }}>
2751
+ <DialogHeader>
2752
+ <DialogHeaderTitle>{gate ? gate.cta : "Hand off"}</DialogHeaderTitle>
2753
+ </DialogHeader>
2754
+ <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 14 }}>
2755
+ <FormField label={gate ? `Assignee at ${stageOf(gate.next).label}` : "Assignee"}>
2756
+ <MemberSelect members={TEAM} value={handoffAssignee} onValueChange={setHandoffAssignee} placeholder="Who receives the record…" />
2757
+ </FormField>
2758
+ <FormTextInput label="Note" optional placeholder="What the next desk should know…" value={handoffNote} onChangeText={setHandoffNote} multiline accessibilityLabel="Handoff note" />
2759
+ </View>
2760
+ <DialogFooter>
2761
+ <Button title="Cancel" color="muted" onPress={() => { setHandoffOpen(false); setHandoffAssignee(null); setHandoffNote(""); }} />
2762
+ {/* one required field, right there — the adjacent-input exception:
2763
+ disabled until the receiver is chosen */}
2764
+ <Button title={gate ? gate.cta : "Hand off"} color="primary" disabled={handoffAssignee == null} onPress={confirmHandoff} />
2765
+ </DialogFooter>
2766
+ </Dialog>
1368
2767
  {/* Balances the rail so the reading column lands dead-centre. In the docs
1369
2768
  layout the right-hand TOC does this job; a record surface has no TOC,
1370
2769
  so the gutter is simply reserved. */}
1371
2770
  {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
1372
2771
  </View>
1373
2772
  </ScrollView>
2773
+
2774
+ {/* ── the document desk's overlays (verbatim from tpl_documents) */}
2775
+ <FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
2776
+ <Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
2777
+ <Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
2778
+ <Button title="Use AI" color="primary" onPress={openAi} />
2779
+ </FloatingActionBar>
2780
+
2781
+ {/* Use AI — fork → running → review → done, all inside one dialog. The review
2782
+ provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
2783
+ commit bar (`DialogFooter`) share one review context. */}
2784
+ <ChangeReview>
2785
+ <Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
2786
+ <DialogHeader>
2787
+ <DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
2788
+ </DialogHeader>
2789
+ <DialogScrollArea>
2790
+ {phase === "fork" ? (
2791
+ <View style={{ gap: 16 }}>
2792
+ <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
2793
+ <View style={{ gap: 8 }}>
2794
+ <CardSelectItem accessibilityLabel="Extract data — read the documents and fill the record" onPress={() => setTaskChoice("extract")} selected={taskChoice === "extract"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
2795
+ <Icon name="scan" size={18} color={colors.zinc[700]} />
2796
+ <View style={{ flex: 1, gap: 2 }}>
2797
+ <Text size="sm" weight="semibold">Extract data</Text>
2798
+ <Text size="xs" color="muted">Read the documents and fill the record — what's new, what changes, what conflicts.</Text>
2799
+ </View>
2800
+ </CardSelectItem>
2801
+ <CardSelectItem accessibilityLabel="Cross-check — compare the documents against the record" onPress={() => setTaskChoice("check")} selected={taskChoice === "check"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
2802
+ <Icon name="list-checks" size={18} color={colors.zinc[700]} />
2803
+ <View style={{ flex: 1, gap: 2 }}>
2804
+ <Text size="sm" weight="semibold">Cross-check</Text>
2805
+ <Text size="xs" color="muted">Compare the documents against the record and each other — what disagrees and why.</Text>
2806
+ </View>
2807
+ </CardSelectItem>
2808
+ <CardSelectItem accessibilityLabel="Edit with AI — open the chat agent with these documents" onPress={() => setTaskChoice("edit")} selected={taskChoice === "edit"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
2809
+ <Icon name="square-pen" size={18} color={colors.zinc[700]} />
2810
+ <View style={{ flex: 1, gap: 2 }}>
2811
+ <Text size="sm" weight="semibold">Edit with AI</Text>
2812
+ <Text size="xs" color="muted">Open the chat with the documents attached — describe the change, the agent edits them as new versions.</Text>
2813
+ </View>
2814
+ </CardSelectItem>
2815
+ </View>
2816
+ {/* Optional steering for the CHECK — findings serve ANY file-based
2817
+ request the user briefs, not just the stock cross-check. */}
2818
+ {taskChoice === "check" ? (
2819
+ <FormTextInput
2820
+ label="Instructions (optional)"
2821
+ placeholder="Anything specific to check — e.g. verify the commodity codes against the order"
2822
+ value={brief}
2823
+ onChangeText={setBrief}
2824
+ multiline
2825
+ accessibilityLabel="Instructions for the agent"
2826
+ />
2827
+ ) : null}
2828
+ </View>
2829
+ ) : null}
2830
+
2831
+ {phase === "running" ? <AgentRun items={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
2832
+
2833
+ {phase === "review" && task === "extract" ? (
2834
+ <View style={{ gap: 16 }}>
2835
+ <View style={{ gap: 8 }}>
2836
+ <Text size="md" weight="semibold">Files read</Text>
2837
+ <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
2838
+ </View>
2839
+ <View style={{ gap: 8 }}>
2840
+ <ChangeReviewHeader />
2841
+ {/* ONE section for the RECORD — ChangeFields stacks a ChangeField
2842
+ per proposed value: label · the − band when replacing · the
2843
+ editable + value. Editing IS the review; each field decides
2844
+ for ITSELF (Keep/Drop), and Apply commits the kept rows. */}
2845
+ <Change id="order">
2846
+ <ChangeReasoning>{EXTRACT_REASONING}</ChangeReasoning>
2847
+ <ChangeFields>
2848
+ {/* An ADD — the record holds nothing yet, so no `before`. */}
2849
+ <ChangeField label="Carrier reference" value={carrierRef} summary={carrierRef} {...fieldRow("carrier_ref")}>
2850
+ <ChangeValueInput value={carrierRef} onChangeText={setCarrierRef} accessibilityLabel="Carrier reference" />
2851
+ </ChangeField>
2852
+ {/* An UPDATE — the current value banded above the editor. */}
2853
+ <ChangeField label="Vessel" value={vessel} summary={vessel} before={VESSEL_CURRENT} {...fieldRow("vessel")}>
2854
+ <ChangeValueInput value={vessel} onChangeText={setVessel} accessibilityLabel="Vessel" />
2855
+ </ChangeField>
2856
+ {/* A CONFLICT — the sources disagree: the read-only outcome
2857
+ band stays on its placeholder until the user picks a
2858
+ candidate below or types a third value — never a
2859
+ pre-selection; Keep is gated until resolved. */}
2860
+ <ChangeField
2861
+ label="Consignee"
2862
+ value={consignee ?? ""}
2863
+ summary={consignee ?? undefined}
2864
+ before={CONSIGNEE_CURRENT}
2865
+ valueReadOnly
2866
+ placeholder="Pick a candidate below"
2867
+ reasoning="The invoice and the packing list disagree — pick a candidate or type your own."
2868
+ candidates={CONSIGNEE_OPTIONS.map((c, i2) => ({ value: c.value, source: c.source, selected: consigneePick === i2 }))}
2869
+ onPickCandidate={(c) => { const i2 = CONSIGNEE_OPTIONS.findIndex((x) => x.value === c.value); setConsigneePick(i2); setConsignee(c.value); }}
2870
+ customValue={customConsignee}
2871
+ customSelected={consigneePick === "custom"}
2872
+ onCustomSelect={() => { setConsigneePick("custom"); setConsignee(customConsignee || null); }}
2873
+ onCustomValue={(v) => { setCustomConsignee(v); setConsignee(v || null); }}
2874
+ keepDisabled={consignee == null}
2875
+ {...fieldRow("consignee")}
2876
+ />
2877
+ {/* An ADD-only field — new information the documents carry:
2878
+ the + band alone, same grammar as every diff. */}
2879
+ <ChangeField label="Gross weight" value={grossWeight} summary={grossWeight} {...fieldRow("gross")}>
2880
+ <ChangeValueInput value={grossWeight} onChangeText={setGrossWeight} unit="kg" accessibilityLabel="Gross weight" />
2881
+ </ChangeField>
2882
+ {/* A REMOVAL — the − band alone (empty value, no editor): the
2883
+ documents show this value no longer applies. */}
2884
+ <ChangeField
2885
+ label="Notify party"
2886
+ before={NOTIFY_CURRENT}
2887
+ reasoning="No notify party appears on any document — the consignee is notified directly."
2888
+ {...fieldRow("notify")}
2889
+ />
2890
+ </ChangeFields>
2891
+ </Change>
2892
+ {/* RECORD ops — the packing list also proposes ORDER LINES:
2893
+ a ChangeRecord card per item (add = one card decision;
2894
+ edit = its changed fields decide themselves). A divider +
2895
+ breathing room set the sub-section off from the fields. */}
2896
+ <View style={{ paddingTop: 10 }}>
2897
+ <Divider />
2898
+ </View>
2899
+ <ChangeReviewHeader title="Order lines" />
2900
+ <ChangeRecord
2901
+ id="line-add"
2902
+ tone="add"
2903
+ title="New line"
2904
+ status={lineAdd}
2905
+ onAccept={() => setLineAdd("accepted")}
2906
+ onReject={() => setLineAdd("rejected")}
2907
+ onUndo={() => setLineAdd("pending")}
2908
+ summary={`${newItem} (${newQty} pcs)`}
2909
+ >
2910
+ <ChangeField label="Item" value={newItem} summary={newItem}>
2911
+ <ChangeValueInput value={newItem} onChangeText={setNewItem} accessibilityLabel="Item" />
2912
+ </ChangeField>
2913
+ <ChangeField label="Quantity" value={newQty} summary={`${newQty} pcs`}>
2914
+ <ChangeValueInput value={newQty} onChangeText={setNewQty} unit="pcs" accessibilityLabel="Quantity" />
2915
+ </ChangeField>
2916
+ </ChangeRecord>
2917
+ <ChangeRecord id="line-edit" tone="edit" title="Flat-pack cartons — existing line" summary={`Flat-pack cartons — ${editedQty} pcs`}>
2918
+ <ChangeField label="Quantity" before="1,200 pcs" value={editedQty} summary={`${editedQty} pcs`} {...lineEditRow("qty")}>
2919
+ <ChangeValueInput value={editedQty} onChangeText={setEditedQty} unit="pcs" accessibilityLabel="Line quantity" />
2920
+ </ChangeField>
2921
+ </ChangeRecord>
2922
+ </View>
2923
+ </View>
2924
+ ) : null}
2925
+
2926
+ {phase === "review" && task === "check" ? (
2927
+ <View style={{ gap: 16 }}>
2928
+ <View style={{ gap: 8 }}>
2929
+ <Text size="md" weight="semibold">Documents checked</Text>
2930
+ <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
2931
+ </View>
2932
+ <View style={{ gap: 14 }}>
2933
+ <ChangeReviewHeader title="Findings" />
2934
+ {/* Display-only: findings inform the verdict the footer records.
2935
+ The kit `Finding` owns severity word · title · detail · the
2936
+ PROMINENT metric · Sources; hairlines separate them. */}
2937
+ {FINDINGS.map((c, i) => (
2938
+ <Fragment key={c.id}>
2939
+ {i > 0 ? <Divider /> : null}
2940
+ <Finding
2941
+ severity={c.severity}
2942
+ title={c.title}
2943
+ detail={c.detail}
2944
+ sources={(c.sources ?? []).map((name): SourceRef => ({ id: name, label: name, kind: "document" }))}
2945
+ onOpenSource={() => { /* preview stub */ }}
2946
+ >
2947
+ {c.comparison ? <FindingComparison values={c.comparison.values} delta={c.comparison.delta} /> : null}
2948
+ </Finding>
2949
+ </Fragment>
2950
+ ))}
2951
+ </View>
2952
+ </View>
2953
+ ) : null}
2954
+
2955
+ {phase === "done" ? (
2956
+ <CompletionState title={`${applyCount} ${applyCount === 1 ? "change" : "changes"} applied to ${code}`} summary="The files stay on the record — each updated field keeps its source document." />
2957
+ ) : null}
2958
+ </DialogScrollArea>
2959
+ {phase === "fork" ? (
2960
+ <DialogFooter>
2961
+ {uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
2962
+ <Button
2963
+ title={taskChoice === "extract" ? "Extract data" : taskChoice === "check" ? "Run cross-check" : taskChoice === "edit" ? "Open chat" : "Run"}
2964
+ color="primary"
2965
+ disabled={taskChoice == null}
2966
+ onPress={() => {
2967
+ if (!taskChoice) return;
2968
+ if (uploadFlow) commitUpload();
2969
+ if (taskChoice === "edit") {
2970
+ // The app→chat handoff — a real app calls the SDK and closes:
2971
+ // void askAi({
2972
+ // file_ids: picked.map((f) => f.id),
2973
+ // record_ids: [orderRecordId],
2974
+ // prompt: "Update these documents — ",
2975
+ // });
2976
+ // The messenger opens a fresh chat with the files attached (a
2977
+ // single file also opens previewed beside it); the prompt is
2978
+ // prefilled, editable, never auto-sent.
2979
+ editWithAi(picked);
2980
+ closeAi();
2981
+ return;
2982
+ }
2983
+ startTask(taskChoice);
2984
+ }}
2985
+ />
2986
+ </DialogFooter>
2987
+ ) : phase === "review" && task === "extract" ? (
2988
+ <DialogFooter>
2989
+ {/* N = the kept fields; the minKept swap disables Apply exactly at
2990
+ N = 0 (the registry can't see field-level decisions, so the host
2991
+ gates — and hands Keep-all its own handler). */}
2992
+ <ChangeReviewActions onAcceptAll={keepAll} onDiscard={closeAi} discardLabel="Cancel" onApply={() => setPhase("done")} applyLabel={`Update record (${applyCount})`} minKept={0} applyDisabled={applyCount === 0} />
2993
+ </DialogFooter>
2994
+ ) : phase === "review" && task === "check" ? (
2995
+ <DialogFooter>
2996
+ {/* The findings ARE the outcome — the human reads them and goes to
2997
+ act (Extract, a manual fix). No phantom "record verdict" write:
2998
+ a persisted check-status would go stale on the next edit. */}
2999
+ <Button title="Done" color="primary" onPress={() => { sel.clear(); closeAi(); }} />
3000
+ </DialogFooter>
3001
+ ) : phase === "done" ? (
3002
+ <DialogFooter>
3003
+ <Button title="Done" color="primary" onPress={() => { sel.clear(); closeAi(); }} />
3004
+ </DialogFooter>
3005
+ ) : null}
3006
+ </Dialog>
3007
+ </ChangeReview>
3008
+
3009
+ {/* Rename — small focused dialog; Save disabled while empty. */}
3010
+ <Dialog open={renameTarget != null} onOpenChange={(o) => { if (!o) setRenameTarget(null); }} maxWidth={420}>
3011
+ <DialogHeader><DialogHeaderTitle>Rename file</DialogHeaderTitle></DialogHeader>
3012
+ <DialogScrollArea>
3013
+ <FormTextInput label="Filename" value={renameDraft} onChangeText={setRenameDraft} accessibilityLabel="Filename" />
3014
+ </DialogScrollArea>
3015
+ <DialogFooter>
3016
+ <Button title="Cancel" color="muted" onPress={() => setRenameTarget(null)} />
3017
+ <Button title="Save" color="primary" disabled={renameDraft.trim() === ""} onPress={saveRename} />
3018
+ </DialogFooter>
3019
+ </Dialog>
3020
+ {/* Mounted only while open — its per-file hooks want a stable list. */}
3021
+ {preview ? (
3022
+ <FileGalleryModal
3023
+ files={preview.files}
3024
+ activeIndex={preview.index}
3025
+ onIndexChange={(i) => setPreview((p2) => (i == null || !p2 ? null : { ...p2, index: i }))}
3026
+ />
3027
+ ) : null}
3028
+
1374
3029
  {/* The rail floats over its reserved gutter — pinned while the record
1375
3030
  scrolls, and out of the scroll row so the page stays wheel-scrollable
1376
3031
  edge to edge. */}
1377
3032
  {pageWidth == null || !wide ? null : (
1378
- <View style={{ position: "absolute", top: 28, left: railLeft, width: RAIL_W }}>
3033
+ <View style={{ position: "absolute", top: 28, left: railLeft, width: RAIL_W, gap: 10 }}>
3034
+ {/* THE PANEL STANDARD: it opens with BACK — the circular glyph, the
3035
+ collection it returns to named beside it (the register route in a
3036
+ real app), visually apart from the outline items. */}
3037
+ <BackButton label="Records" onPress={() => {}} />
1379
3038
  <View style={{ gap: 2 }}>
1380
3039
  {SECTIONS.map((sec) => (
1381
3040
  <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />