@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.
- package/AGENTS.md +13 -0
- package/docs/ai_patterns.md +16 -0
- package/docs/catalog.md +37 -19
- package/docs/composition.md +60 -3
- package/docs/data_entry.md +26 -7
- package/docs/templates.md +148 -50
- package/examples/tpl_documents.tsx +29 -18
- package/examples/tpl_item_list.tsx +27 -8
- package/examples/tpl_record.tsx +2214 -555
- package/package.json +1 -1
- package/src/back_button.tsx +26 -33
- package/src/checklist.tsx +30 -15
- package/src/comments_thread.tsx +3 -62
- package/src/detail_row.tsx +86 -40
- package/src/inline_edit.tsx +5 -0
- package/src/inline_select.tsx +5 -5
- package/src/radio_picker.tsx +11 -2
- package/src/section_heading.tsx +3 -3
- package/src/section_stack.tsx +6 -4
- package/src/text_input_field.tsx +6 -0
- package/src/timeline.tsx +9 -9
package/examples/tpl_record.tsx
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import
|
|
42
|
-
import {
|
|
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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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
|
|
93
|
-
{ key: "sales", label: "Sales"
|
|
94
|
-
{ key: "operations", label: "Operations"
|
|
95
|
-
{ key: "accounting", label: "Accounting"
|
|
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
|
|
147
|
+
const STAGES: { key: Stage; label: string }[] = [
|
|
98
148
|
...DESKS,
|
|
99
|
-
{ key: "closed", label: "Closed"
|
|
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
|
|
266
|
-
*
|
|
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
|
|
269
|
-
* right
|
|
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,
|
|
317
|
+
inv, gated, onAmount, onMethod, onIssue,
|
|
275
318
|
}: {
|
|
276
319
|
inv: Invoice;
|
|
277
|
-
/** The
|
|
278
|
-
* resolved by the parent —
|
|
279
|
-
|
|
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 &&
|
|
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
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
423
|
-
//
|
|
424
|
-
const [files, setFiles] = useState<
|
|
425
|
-
const
|
|
838
|
+
// ── the document desk (carried verbatim from tpl_documents — see 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: <
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
632
|
-
{ key: "
|
|
633
|
-
{ key: "
|
|
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(["
|
|
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 —
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
{/*
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1443
|
+
{/* GENERAL — the 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
|
|
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
|
-
|
|
790
|
-
<
|
|
791
|
-
<
|
|
792
|
-
<
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
<
|
|
860
|
-
<
|
|
861
|
-
</
|
|
862
|
-
|
|
863
|
-
</
|
|
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
|
-
{/*
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1661
|
+
{/* COMMENTS — the 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="
|
|
894
|
-
<OwnerTag stage="accounting" />
|
|
1669
|
+
<SectionHeadingTitle description="The record's discussion — every question and decision stays with the order.">Comments</SectionHeadingTitle>
|
|
895
1670
|
</SectionHeading>
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
<
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
{/* TASKS — the 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
|
-
|
|
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
|
-
<
|
|
1798
|
+
<Subsection key={g.key}>
|
|
1110
1799
|
{g.head ? (
|
|
1111
|
-
<
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
-
{/*
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
<View style={{
|
|
1197
|
-
{
|
|
1198
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
2038
|
+
)}
|
|
2039
|
+
</Section>
|
|
2040
|
+
</View>
|
|
2041
|
+
|
|
2042
|
+
{/* FEES — the 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
|
-
{/*
|
|
1216
|
-
|
|
1217
|
-
|
|
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="
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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
|
-
|
|
1235
|
-
|
|
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
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
>
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
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
|
|
1277
|
-
|
|
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)} />
|