@lotics/ui 22.2.0 → 23.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 +9 -2
- package/MIGRATION.md +142 -2
- package/docs/ai_patterns.md +27 -25
- package/docs/catalog.md +119 -98
- package/docs/composition.md +134 -42
- package/docs/data_entry.md +67 -31
- package/docs/templates.md +86 -68
- package/examples/tpl_allocate.tsx +5 -5
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +6 -6
- package/examples/tpl_dashboard.tsx +9 -9
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_item_list.tsx +64 -37
- package/examples/tpl_lookup.tsx +5 -5
- package/examples/tpl_pick.tsx +6 -6
- package/examples/tpl_pivot.tsx +3 -3
- package/examples/tpl_record.tsx +919 -611
- package/examples/tpl_report.tsx +2 -2
- package/examples/tpl_rollup.tsx +5 -5
- package/examples/tpl_shifts.tsx +5 -5
- package/examples/tpl_statements.tsx +7 -7
- package/examples/tpl_stock.tsx +2 -2
- package/examples/tpl_task_board.tsx +42 -26
- package/examples/tpl_tower.tsx +6 -6
- package/package.json +3 -2
- package/src/agent_run.tsx +40 -25
- package/src/breakdown.tsx +1 -1
- package/src/calendar/calendar_view.tsx +1 -1
- package/src/change_review.tsx +9 -8
- package/src/chip_group.tsx +12 -2
- package/src/choice_list.tsx +2 -2
- package/src/confidence.tsx +2 -2
- package/src/data_grid.tsx +1 -1
- package/src/detail_row.tsx +50 -58
- package/src/file_dropzone.tsx +1 -1
- package/src/file_gallery_modal.tsx +3 -3
- package/src/file_rows.tsx +1 -1
- package/src/finding.tsx +4 -4
- package/src/form_field.tsx +1 -1
- package/src/format_date.ts +2 -2
- package/src/heatmap.tsx +1 -1
- package/src/inline_button.tsx +84 -0
- package/src/inline_date_picker.tsx +17 -10
- package/src/inline_edit.tsx +298 -59
- package/src/inline_member_select.tsx +8 -3
- package/src/inline_number_input.tsx +11 -4
- package/src/inline_select.tsx +26 -13
- package/src/inline_text_input.tsx +12 -4
- package/src/inline_time_picker.tsx +10 -4
- package/src/ledger.tsx +2 -2
- package/src/locale.tsx +7 -3
- package/src/matrix.tsx +1 -1
- package/src/number_input.tsx +18 -7
- package/src/pipeline.tsx +1 -1
- package/src/popover.tsx +1 -1
- package/src/press_door.tsx +1 -1
- package/src/pressable_highlight.tsx +1 -1
- package/src/progress_bar.tsx +3 -3
- package/src/record_summary.tsx +2 -2
- package/src/result_header.tsx +2 -2
- package/src/sequence.tsx +170 -0
- package/src/share_or_download.ts +2 -2
- package/src/step_progress.tsx +7 -5
- package/src/stepper.tsx +1 -1
- package/src/task.tsx +6 -6
- package/src/text_input_field.tsx +19 -1
- package/src/text_utils.ts +1 -1
- package/src/linked_record_box.tsx +0 -157
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useRef, useState
|
|
1
|
+
import { Fragment, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
@@ -13,7 +13,7 @@ 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
15
|
import { Inset } from "@lotics/ui/inset";
|
|
16
|
-
import { Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
16
|
+
import { DialogSectionHeadingTitle, Section, SectionHeading, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
17
17
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
18
18
|
import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
|
|
19
19
|
import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
|
|
@@ -42,7 +42,6 @@ import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
|
42
42
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
43
43
|
import { FormField } from "@lotics/ui/form_field";
|
|
44
44
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
45
|
-
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
46
45
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
47
46
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
48
47
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
@@ -54,9 +53,13 @@ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
|
54
53
|
import { Finding, FindingComparison } from "@lotics/ui/finding";
|
|
55
54
|
import { FormTextInput } from "@lotics/ui/form_text_input";
|
|
56
55
|
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
57
|
-
import {
|
|
56
|
+
import { Popover, PopoverContent } from "@lotics/ui/popover";
|
|
57
|
+
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
58
|
+
import { Sequence, SequenceItem, SEQUENCE_INSET } from "@lotics/ui/sequence";
|
|
59
|
+
import { TextLink } from "@lotics/ui/text_link";
|
|
60
|
+
import { InlineButton } from "@lotics/ui/inline_button";
|
|
61
|
+
import { InlineEditView } from "@lotics/ui/inline_edit";
|
|
58
62
|
import { RadioPicker } from "@lotics/ui/radio_picker";
|
|
59
|
-
import { SearchInput } from "@lotics/ui/search_input";
|
|
60
63
|
import { useSelection } from "@lotics/ui/use_selection";
|
|
61
64
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
62
65
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
@@ -71,7 +74,7 @@ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
|
71
74
|
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
72
75
|
|
|
73
76
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
-
// Template
|
|
77
|
+
// Template, Record — THE record surface: the surface IS the editor, every
|
|
75
78
|
// field refines in place. No breadcrumb, no create CTA — back lives in the
|
|
76
79
|
// panel and creation belongs to the REGISTER (the list owns "new").
|
|
77
80
|
//
|
|
@@ -83,29 +86,29 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
|
83
86
|
// patterns.
|
|
84
87
|
//
|
|
85
88
|
// THE SECTION PATTERNS (what each is, when to use it):
|
|
86
|
-
//
|
|
89
|
+
// - General — the CORE FACTS: a headingless key-facts lead + named
|
|
87
90
|
// groups + Classification (the right-input-per-field
|
|
88
91
|
// showcase) + System ids. Always present, first in rail.
|
|
89
|
-
//
|
|
90
|
-
//
|
|
92
|
+
// - Comments — the discussion thread. When people collaborate here.
|
|
93
|
+
// - Progress — WHERE the record sits: the desks as a `Pipeline`, each
|
|
91
94
|
// owning its own facts, the live one owning the act that
|
|
92
95
|
// leaves it. Records that move between owners.
|
|
93
|
-
//
|
|
96
|
+
// - Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
|
|
94
97
|
// fork (the Agents "Document desk" pattern — this template
|
|
95
98
|
// is its worked example).
|
|
96
|
-
//
|
|
99
|
+
// - Customer — the LINKED PARTY: another record referenced, never
|
|
97
100
|
// edited here (the linked-record box + drawer).
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
+
// - Fees — the MONEY LEDGER: cost/charge lines, both directions.
|
|
102
|
+
// - Billing — INVOICING: charges grouped into issuable documents.
|
|
103
|
+
// - Document set — the BATCH OUTPUT desk: the record produces per-party
|
|
101
104
|
// form SETS on demand (readiness, fill panels, the gate).
|
|
102
|
-
//
|
|
105
|
+
// - Delivery receipt— the QUICK-ISSUE form: one document issued from a
|
|
103
106
|
// handful of facts at a known moment (a handover, an
|
|
104
107
|
// inspection, a visit).
|
|
105
|
-
//
|
|
106
|
-
//
|
|
108
|
+
// - Activity — the AUDIT TRAIL. Always.
|
|
109
|
+
// - Handoff — the BOUNDARY: the record crosses to another desk's
|
|
107
110
|
// table ONCE (creates + links the sibling; Recall undoes).
|
|
108
|
-
//
|
|
111
|
+
// - Danger zone — destructive lifecycle. Always last.
|
|
109
112
|
//
|
|
110
113
|
// The lifecycle is the HANDOFF CHAIN, and it reads in TWO places by design:
|
|
111
114
|
// `Progress` carries the position and the act that changes it; `Handoff` near
|
|
@@ -183,6 +186,41 @@ const lookupRegistry = (taxId: string) =>
|
|
|
183
186
|
setTimeout(() => resolve(REGISTRY[taxId] ?? { contact: "Accounts desk", city: "Ho Chi Minh City" }), 700);
|
|
184
187
|
});
|
|
185
188
|
|
|
189
|
+
// TRANSPORT's option sets — generic movement vocabulary, at the level any
|
|
190
|
+
// goods business shares. A template teaches the SHAPE; one industry's jargon in
|
|
191
|
+
// a template is a trap for every reader from another.
|
|
192
|
+
const DOC_TYPES = [
|
|
193
|
+
{ value: "original", label: "Original", data: { hint: "Printed set, released against a surrendered copy" } },
|
|
194
|
+
{ value: "electronic", label: "Electronic release", data: { hint: "No paper to lose; release is a message" } },
|
|
195
|
+
{ value: "waybill", label: "Straight waybill", data: { hint: "Named consignee, not transferable" } },
|
|
196
|
+
];
|
|
197
|
+
const DELIVERY_TERMS = [
|
|
198
|
+
{ value: "exw", label: "Ex works" },
|
|
199
|
+
{ value: "fca", label: "Free carrier" },
|
|
200
|
+
{ value: "cpt", label: "Carriage paid to" },
|
|
201
|
+
{ value: "dap", label: "Delivered at place" },
|
|
202
|
+
{ value: "ddp", label: "Delivered duty paid" },
|
|
203
|
+
];
|
|
204
|
+
const CHARGE_TERMS = [
|
|
205
|
+
{ value: "prepaid", label: "Prepaid", data: { hint: "The sender pays the carrier" } },
|
|
206
|
+
{ value: "collect", label: "Collect", data: { hint: "The receiver pays on arrival" } },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
/** Choice + its gloss, shown in the OPTION LIST rather than on the page. The
|
|
210
|
+
* explanation is needed while CHOOSING, not on every later read of the row. */
|
|
211
|
+
const choiceWithHint = (o: { label?: string; data?: { hint: string } }) => (
|
|
212
|
+
<View style={{ gap: 1 }}>
|
|
213
|
+
<Text size="sm">{o.label}</Text>
|
|
214
|
+
{o.data ? <Text size="xs" color="muted">{o.data.hint}</Text> : null}
|
|
215
|
+
</View>
|
|
216
|
+
);
|
|
217
|
+
const SCHEDULE_STATES = [
|
|
218
|
+
{ value: "on-time", label: "On time" },
|
|
219
|
+
{ value: "delayed", label: "Delayed" },
|
|
220
|
+
{ value: "rerouted", label: "Rerouted" },
|
|
221
|
+
{ value: "cancelled", label: "Sailing cancelled" },
|
|
222
|
+
];
|
|
223
|
+
|
|
186
224
|
const TERMS = [
|
|
187
225
|
{ value: "receipt", label: "Due on receipt" },
|
|
188
226
|
{ value: "15", label: "Net 15" },
|
|
@@ -255,9 +293,7 @@ const BILLING_INITIAL: Invoice[] = [
|
|
|
255
293
|
},
|
|
256
294
|
];
|
|
257
295
|
|
|
258
|
-
type InvoiceState = "none" | "draft" | "issued";
|
|
259
296
|
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
260
|
-
const invoiceStatus = (inv: Invoice): InvoiceState => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
|
|
261
297
|
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
262
298
|
|
|
263
299
|
const money = (v: number | null) => (v == null ? "" : formatMoney(v));
|
|
@@ -273,102 +309,6 @@ function persist<T>(set: (v: T) => void) {
|
|
|
273
309
|
});
|
|
274
310
|
}
|
|
275
311
|
|
|
276
|
-
/** One invoice document — a named SUBSECTION (the Billing section's groups all
|
|
277
|
-
* ride the one SubsectionStack beat): its heading, then the charge lines as
|
|
278
|
-
* inline rows (amount chip with the list price as ghost text + a one-tap
|
|
279
|
-
* "Standard …" suggestion pill while unset; the payment method turns required
|
|
280
|
-
* the moment the line carries an amount), then the ACTION alone at the bottom
|
|
281
|
-
* right — no prose beside a CTA; a not-ready Issue simply stays disabled
|
|
282
|
-
* (the stage/customer gates read at THEIR sections).
|
|
283
|
-
* No per-band total — the collect band below owns the number. An ISSUED
|
|
284
|
-
* invoice keeps its lines editable — fees change — and offers Re-issue (a new
|
|
285
|
-
* lookup code replaces the old, confirmed in the same Dialog). */
|
|
286
|
-
function InvoiceBand({
|
|
287
|
-
inv, gated, onAmount, onMethod, onIssue,
|
|
288
|
-
}: {
|
|
289
|
-
inv: Invoice;
|
|
290
|
-
/** The record-level gate (wrong stage / no customer / invalid tax ID),
|
|
291
|
-
* resolved by the parent — issuing stays disabled while true. */
|
|
292
|
-
gated: boolean;
|
|
293
|
-
onAmount: (chKey: string, v: number) => Promise<void>;
|
|
294
|
-
onMethod: (chKey: string, m: Method) => Promise<void>;
|
|
295
|
-
/** Takes the invoice's KEY, never the `inv` object: the press may be held while
|
|
296
|
-
* a charge cell finishes writing (Button waits out an inline commit), and a
|
|
297
|
-
* captured object would still be the pre-edit snapshot. Resolve at use. */
|
|
298
|
-
onIssue: (invKey: string) => void;
|
|
299
|
-
}) {
|
|
300
|
-
const total = invoiceTotal(inv);
|
|
301
|
-
const state = invoiceStatus(inv);
|
|
302
|
-
const missing = missingMethods(inv);
|
|
303
|
-
const ready = total > 0 && missing.length === 0 && !gated;
|
|
304
|
-
return (
|
|
305
|
-
<Subsection>
|
|
306
|
-
<SubsectionHeading>
|
|
307
|
-
<SubsectionHeadingTitle>{inv.title}</SubsectionHeadingTitle>
|
|
308
|
-
</SubsectionHeading>
|
|
309
|
-
{/* two editors per cell → stack earlier than a single-editor table */}
|
|
310
|
-
<DetailTable labelWidth={150} minValueWidth={320}>
|
|
311
|
-
{inv.charges.map((c) => (
|
|
312
|
-
<DetailRow key={c.key} label={c.label}>
|
|
313
|
-
<View style={{ gap: 4 }}>
|
|
314
|
-
{/* amount + method WRAP on narrow — a fixed 160 method next to a
|
|
315
|
-
flex amount crushes the amount chip to nothing on phones */}
|
|
316
|
-
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
|
|
317
|
-
<View style={{ flexGrow: 1, flexBasis: 150 }}>
|
|
318
|
-
<InlineNumberInput
|
|
319
|
-
value={c.amount || null}
|
|
320
|
-
onSave={(v) => onAmount(c.key, v ?? 0)}
|
|
321
|
-
min={0}
|
|
322
|
-
format={money}
|
|
323
|
-
placeholder={c.standard > 0 ? money(c.standard) : "—"}
|
|
324
|
-
accessibilityLabel={`${c.label} amount`}
|
|
325
|
-
/>
|
|
326
|
-
</View>
|
|
327
|
-
<View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
|
|
328
|
-
<InlineSelect
|
|
329
|
-
value={c.method === "" ? null : c.method}
|
|
330
|
-
onSave={(m) => onMethod(c.key, m)}
|
|
331
|
-
options={METHODS}
|
|
332
|
-
placeholder="How paid…"
|
|
333
|
-
disabled={c.amount <= 0}
|
|
334
|
-
accessibilityLabel={`Payment method · ${c.label}`}
|
|
335
|
-
/>
|
|
336
|
-
</View>
|
|
337
|
-
</View>
|
|
338
|
-
{/* the one-tap suggestion: fill the list price while unset */}
|
|
339
|
-
{c.amount <= 0 && c.standard > 0 ? (
|
|
340
|
-
<View style={{ flexDirection: "row" }}>
|
|
341
|
-
<Button title={`Standard ${money(c.standard)}`} color="secondary" onPress={() => onAmount(c.key, c.standard)} />
|
|
342
|
-
</View>
|
|
343
|
-
) : null}
|
|
344
|
-
</View>
|
|
345
|
-
</DetailRow>
|
|
346
|
-
))}
|
|
347
|
-
</DetailTable>
|
|
348
|
-
{/* band-LOCAL state, co-located: a charged line without a payment method
|
|
349
|
-
is a real inconsistency → its callout lives in the band. An EMPTY
|
|
350
|
-
band stays silent — a disabled Issue over no fees explains itself. */}
|
|
351
|
-
{missing.length > 0 ? (
|
|
352
|
-
<Callout tone="warning">
|
|
353
|
-
<CalloutText>Choose a payment method for every charged line.</CalloutText>
|
|
354
|
-
</Callout>
|
|
355
|
-
) : null}
|
|
356
|
-
{/* the band's action row — the CTA alone, bottom right (the issued ref
|
|
357
|
-
link IS the issued state); not-ready = disabled, the WHY reads in the
|
|
358
|
-
callouts (band-local above, record-level at the section top) */}
|
|
359
|
-
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", columnGap: 8 }}>
|
|
360
|
-
{state === "issued" ? (
|
|
361
|
-
<>
|
|
362
|
-
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
363
|
-
<Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv.key)} />
|
|
364
|
-
</>
|
|
365
|
-
) : (
|
|
366
|
-
<Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv.key)} />
|
|
367
|
-
)}
|
|
368
|
-
</View>
|
|
369
|
-
</Subsection>
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
312
|
|
|
373
313
|
/** The section rail's width — and, mirrored as an empty right gutter, what
|
|
374
314
|
* keeps the reading column centred in the viewport. */
|
|
@@ -378,6 +318,13 @@ const RAIL_GAP = 48;
|
|
|
378
318
|
/** The reserved gutter: the rail plus its gap. Mirrored empty on the right, this
|
|
379
319
|
* is what centres the column. */
|
|
380
320
|
const GUTTER = RAIL_W + RAIL_GAP;
|
|
321
|
+
/** The discussion panel's column. A record's comments are not a SECTION — they
|
|
322
|
+
* are about the whole record, so they do not belong at one position in a
|
|
323
|
+
* top-to-bottom reading order, and a coworker's note has to stay readable while
|
|
324
|
+
* you work anywhere on the page. It takes the right gutter, which until now was
|
|
325
|
+
* reserved purely to balance the rail. */
|
|
326
|
+
const PANEL_W = 360;
|
|
327
|
+
const PANEL_GUTTER = PANEL_W + RAIL_GAP;
|
|
381
328
|
/** The reading column's ceiling. */
|
|
382
329
|
const CONTENT_MAX = 720;
|
|
383
330
|
|
|
@@ -484,30 +431,9 @@ const SEED_COMMENTS: ThreadComment[] = [
|
|
|
484
431
|
];
|
|
485
432
|
|
|
486
433
|
// The sibling-record counter — each handoff CREATES the next desk's record on
|
|
487
|
-
// ITS table (OP-…/AC-…) and links it here for reference.
|
|
488
|
-
let siblingSeq = 90;
|
|
489
|
-
|
|
490
434
|
/** "2026-07-18" — the wire shape every date FIELD on this surface stores. */
|
|
491
435
|
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
|
492
436
|
|
|
493
|
-
/** "18 Jul, 4:05 PM" — the trail/audit timestamp format. */
|
|
494
|
-
const stamp = (d: Date): string =>
|
|
495
|
-
`${d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
|
|
496
|
-
|
|
497
|
-
/** A sibling record created by a handoff — lives on ITS desk's table; here we
|
|
498
|
-
* keep the reference data the cards + drawer show. */
|
|
499
|
-
interface SiblingRecord {
|
|
500
|
-
id: string;
|
|
501
|
-
code: string;
|
|
502
|
-
/** The desk this handoff OPENED — the key, not just its label, because recall
|
|
503
|
-
* has to undo exactly that step: return to the desk before it and clear the
|
|
504
|
-
* custody stamp it wrote. A label can't be mapped back without a lookup. */
|
|
505
|
-
deskKey: Desk;
|
|
506
|
-
desk: string;
|
|
507
|
-
assignee: string;
|
|
508
|
-
note: string;
|
|
509
|
-
at: string;
|
|
510
|
-
}
|
|
511
437
|
|
|
512
438
|
// ── the FEES ledger — the DETAILED money view (both directions), distinct
|
|
513
439
|
// from Billing's invoice DOCUMENTS: every fee the record incurs or charges,
|
|
@@ -518,7 +444,7 @@ type FeeDirection = "charge" | "cost";
|
|
|
518
444
|
interface Fee {
|
|
519
445
|
id: string;
|
|
520
446
|
name: string;
|
|
521
|
-
/** charge = billed to the customer
|
|
447
|
+
/** charge = billed to the customer, cost = paid out to a vendor. */
|
|
522
448
|
direction: FeeDirection;
|
|
523
449
|
party: string;
|
|
524
450
|
amount: number;
|
|
@@ -656,6 +582,262 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
|
|
|
656
582
|
{ value: "dubai", label: "Dubai", data: { country: "United Arab Emirates" } },
|
|
657
583
|
];
|
|
658
584
|
|
|
585
|
+
/**
|
|
586
|
+
* A REFERENCE to another record, rendered as a FIELD VALUE.
|
|
587
|
+
*
|
|
588
|
+
* It wears the inline editor's resting surface — same 40px band, same zinc-50
|
|
589
|
+
* chip, same radius — so a pointer at another record sits in the value column
|
|
590
|
+
* exactly like the editors above and below it, rather than announcing itself as
|
|
591
|
+
* a different species of thing. (The chip's usual promise is "editable"; here it
|
|
592
|
+
* reads as "pressable", which is what `InlineSelect` and `InlineDatePicker`
|
|
593
|
+
* already mean by it — they open a popover too.)
|
|
594
|
+
*
|
|
595
|
+
* THREE acts, placed by how OFTEN they happen and how much they cost to undo:
|
|
596
|
+
* press the value → the facts, in a popover (free, so it's the whole surface)
|
|
597
|
+
* Open, inside → the record itself (the common act, so it stays in sight)
|
|
598
|
+
* Remove, in the peek → detach (destructive and rare, so it sits one layer in)
|
|
599
|
+
*
|
|
600
|
+
* Remove earns its depth twice over. It is the answer to the question the PEEK
|
|
601
|
+
* asks — "is this the right one?" — so the check and the correction are the same
|
|
602
|
+
* gesture; and a detach link repeated down a column of references is noise in the
|
|
603
|
+
* scan path for an act most readers never perform. Depth is not a hiding place
|
|
604
|
+
* here: pressing a field-shaped value is the first thing anyone tries, and the
|
|
605
|
+
* verb is plainly visible once open.
|
|
606
|
+
*
|
|
607
|
+
* The anatomy is the one a container-with-controls REQUIRES and that hand-rolling
|
|
608
|
+
* reliably gets wrong: the surface is a role-less, non-focusable `Pressable` (a
|
|
609
|
+
* button must never contain a button), an empty `PressDoor` sibling carries the
|
|
610
|
+
* tab stop / accessible name / focus ring, and the interior verb lifts above the
|
|
611
|
+
* door on `zIndex`. Hover is tracked on the container itself so the border stays
|
|
612
|
+
* lit while the pointer crosses Open — react-native-web otherwise releases the
|
|
613
|
+
* parent's hover to the innermost pressable and the edge flickers out.
|
|
614
|
+
*
|
|
615
|
+
* This is a TEMPLATE composition, not yet a kit primitive: it earns extraction on
|
|
616
|
+
* its second consumer, and that extraction is about this ANATOMY, not the layout.
|
|
617
|
+
*/
|
|
618
|
+
function ReferenceValue(props: {
|
|
619
|
+
name: string;
|
|
620
|
+
code?: string;
|
|
621
|
+
facts: { label: string; value: string }[];
|
|
622
|
+
/** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
|
|
623
|
+
accessibilityLabel: string;
|
|
624
|
+
onOpen: () => void;
|
|
625
|
+
openLabel: string;
|
|
626
|
+
/** Detach — omit where the link cannot be broken (a handoff's sibling record). */
|
|
627
|
+
onRemove?: () => void;
|
|
628
|
+
}) {
|
|
629
|
+
const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
|
|
630
|
+
const anchor = useRef<View>(null);
|
|
631
|
+
const [peekOpen, setPeekOpen] = useState(false);
|
|
632
|
+
return (
|
|
633
|
+
<Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
|
|
634
|
+
{/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
|
|
635
|
+
wearing copies of the field's border, radius, height and hover — and it
|
|
636
|
+
promptly fell out of step the moment the kit's field changed: it kept a
|
|
637
|
+
pointer cursor no editor has, and missed the hover TINT that every other
|
|
638
|
+
field gained. A copied surface always drifts; the fix is to stop copying.
|
|
639
|
+
|
|
640
|
+
`InlineEditView` gives all of it: the surface and its states, the verb as
|
|
641
|
+
a SIBLING of the press target (a button must not contain a button), hover
|
|
642
|
+
tracked on the box so the border holds while the pointer crosses the
|
|
643
|
+
verb, and focus-within. `anchorRef` hands the popover the FIELD's box —
|
|
644
|
+
the press target inside is narrower, and a peek anchored there would
|
|
645
|
+
open misaligned with the row it describes.
|
|
646
|
+
|
|
647
|
+
OPEN, never toggle: the press target and the popover's own dismissal
|
|
648
|
+
would otherwise fight, and idempotent `true` makes the pair safe. The
|
|
649
|
+
popover closes on outside press / Escape. */}
|
|
650
|
+
<InlineEditView
|
|
651
|
+
anchorRef={anchor}
|
|
652
|
+
accessibilityLabel={accessibilityLabel}
|
|
653
|
+
onPress={() => setPeekOpen(true)}
|
|
654
|
+
active={peekOpen}
|
|
655
|
+
display={
|
|
656
|
+
<View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
657
|
+
<Text size="sm" numberOfLines={1}>{name}</Text>
|
|
658
|
+
{code ? <Text size="sm" color="muted" numberOfLines={1}>{code}</Text> : null}
|
|
659
|
+
</View>
|
|
660
|
+
}
|
|
661
|
+
actions={
|
|
662
|
+
/* Dismiss the peek on the way out: the verb sits inside the popover's
|
|
663
|
+
own trigger and stops propagation, so nothing else will close it —
|
|
664
|
+
and a summary left hanging over the record you just opened describes
|
|
665
|
+
what you are now looking at. */
|
|
666
|
+
<InlineButton title="Open" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
|
|
667
|
+
}
|
|
668
|
+
/>
|
|
669
|
+
{/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
|
|
670
|
+
is dialog-scale, so the identity takes the ramp's dialog rung
|
|
671
|
+
(`DialogSectionHeadingTitle`, ####) with the code as its description
|
|
672
|
+
rather than a hand-picked font weight; the facts are `DetailRow`s, which
|
|
673
|
+
is what label-beside-value IS everywhere else on this page; and the
|
|
674
|
+
destructive verb is fenced off by a `Divider` instead of floating after
|
|
675
|
+
the last fact. Width matches `Peek`'s own content width so every peek in
|
|
676
|
+
an app is the same object.
|
|
677
|
+
`labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
|
|
678
|
+
STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
|
|
679
|
+
page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
|
|
680
|
+
the summary side-by-side, which is the whole point of a glance. */}
|
|
681
|
+
<PopoverContent style={{ width: 320 }} disableBodyScroll>
|
|
682
|
+
<View style={{ gap: 12 }}>
|
|
683
|
+
<DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
|
|
684
|
+
<DetailTable labelWidth={88}>
|
|
685
|
+
{facts.map((f) => (
|
|
686
|
+
<DetailRow key={f.label} label={f.label} flat>
|
|
687
|
+
<InlineStatic value={f.value || "—"} />
|
|
688
|
+
</DetailRow>
|
|
689
|
+
))}
|
|
690
|
+
</DetailTable>
|
|
691
|
+
{/* THE FOOTER — destructive LEFT, go-to RIGHT, the kit's convention.
|
|
692
|
+
Open repeats the verb on the surface deliberately: the popover now
|
|
693
|
+
covers the button that opened it, and having read the facts, "take me
|
|
694
|
+
there" is the next move. That is the shape `Peek` prescribes — a
|
|
695
|
+
summary with one action to the full record. Remove closes the peek
|
|
696
|
+
first, because the surface it is anchored to is about to stop
|
|
697
|
+
existing. */}
|
|
698
|
+
<Divider />
|
|
699
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
700
|
+
{onRemove ? (
|
|
701
|
+
// No icon: an ✕ beside "Remove" says it twice, and the glyph alone
|
|
702
|
+
// made this read as the popover's dismiss rather than a decision.
|
|
703
|
+
<Button title="Remove" color="danger" onPress={() => { setPeekOpen(false); onRemove(); }} />
|
|
704
|
+
) : null}
|
|
705
|
+
<View style={{ flex: 1 }} />
|
|
706
|
+
<Button title="Open" color="secondary" onPress={() => { setPeekOpen(false); onOpen(); }} />
|
|
707
|
+
</View>
|
|
708
|
+
</View>
|
|
709
|
+
</PopoverContent>
|
|
710
|
+
</Popover>
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** One stop on a route. `place` + when the movement is expected there; the ROLE
|
|
715
|
+
* (origin, via, destination) is DERIVED from position, never stored — a stored
|
|
716
|
+
* role lies the moment the list is reordered. */
|
|
717
|
+
interface Stop {
|
|
718
|
+
id: string;
|
|
719
|
+
place: string;
|
|
720
|
+
eta: string | null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* A ROUTE — the kit's `Sequence`, because a route is an ordered list whose ORDER
|
|
725
|
+
* is the data.
|
|
726
|
+
*
|
|
727
|
+
* The three fields this replaces (origin, transfer point, destination) could
|
|
728
|
+
* not express either thing a route IS: it is ordered, and it has as many stops
|
|
729
|
+
* as it has. A second transfer had nowhere to go, and three identical grey boxes
|
|
730
|
+
* said nothing about which came first.
|
|
731
|
+
*
|
|
732
|
+
* The kit draws the rail, the reorder/remove controls and the spacing; the
|
|
733
|
+
* template supplies what a stop CONTAINS and what its position MEANS.
|
|
734
|
+
*/
|
|
735
|
+
function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[]) => void }) {
|
|
736
|
+
const patch = (id: string, next: Partial<Stop>) =>
|
|
737
|
+
onChange(stops.map((s) => (s.id === id ? { ...s, ...next } : s)));
|
|
738
|
+
const swap = (i: number, j: number) => {
|
|
739
|
+
const next = [...stops];
|
|
740
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
741
|
+
onChange(next);
|
|
742
|
+
};
|
|
743
|
+
return (
|
|
744
|
+
<View style={{ gap: 6 }}>
|
|
745
|
+
<Sequence>
|
|
746
|
+
{stops.map((stop, i) => {
|
|
747
|
+
// Derived, so a move or an insert relabels its neighbours for free.
|
|
748
|
+
const role = i === 0 ? "Origin" : i === stops.length - 1 ? "Destination" : "Via";
|
|
749
|
+
return (
|
|
750
|
+
<SequenceItem
|
|
751
|
+
key={stop.id}
|
|
752
|
+
role={role}
|
|
753
|
+
accessibilityName={stop.place || role}
|
|
754
|
+
onMoveUp={() => swap(i, i - 1)}
|
|
755
|
+
onMoveDown={() => swap(i, i + 1)}
|
|
756
|
+
// A route needs its two ends; only what is BETWEEN them can go.
|
|
757
|
+
onRemove={stops.length > 2 ? () => onChange(stops.filter((s) => s.id !== stop.id)) : undefined}
|
|
758
|
+
>
|
|
759
|
+
<View style={{ flexGrow: 1, flexBasis: 150, minWidth: 0 }}>
|
|
760
|
+
<InlineTextInput
|
|
761
|
+
value={stop.place}
|
|
762
|
+
onSave={(v) => patch(stop.id, { place: v })}
|
|
763
|
+
placeholder="Add a place…"
|
|
764
|
+
accessibilityLabel={`${role} place`}
|
|
765
|
+
/>
|
|
766
|
+
</View>
|
|
767
|
+
<View style={{ width: 130 }}>
|
|
768
|
+
<InlineDatePicker
|
|
769
|
+
value={stop.eta}
|
|
770
|
+
onSave={(v) => patch(stop.id, { eta: v })}
|
|
771
|
+
onClear={() => patch(stop.id, { eta: null })}
|
|
772
|
+
locale="en-US"
|
|
773
|
+
placeholder="Expected…"
|
|
774
|
+
accessibilityLabel={`${role} expected date`}
|
|
775
|
+
/>
|
|
776
|
+
</View>
|
|
777
|
+
</SequenceItem>
|
|
778
|
+
);
|
|
779
|
+
})}
|
|
780
|
+
</Sequence>
|
|
781
|
+
{/* Indented onto the rail's own column, so the affordance to grow the list
|
|
782
|
+
sits under the list rather than beside it. */}
|
|
783
|
+
<View style={{ flexDirection: "row", paddingLeft: SEQUENCE_INSET }}>
|
|
784
|
+
{/* A stop is added BEFORE the destination — a new leg is always in the
|
|
785
|
+
middle of a journey; nobody adds one past the end. */}
|
|
786
|
+
<TextLink
|
|
787
|
+
size="sm"
|
|
788
|
+
onPress={() =>
|
|
789
|
+
onChange([
|
|
790
|
+
...stops.slice(0, -1),
|
|
791
|
+
{ id: `st_${stops.length}_${stops[stops.length - 1].id}`, place: "", eta: null },
|
|
792
|
+
stops[stops.length - 1],
|
|
793
|
+
])
|
|
794
|
+
}
|
|
795
|
+
>
|
|
796
|
+
Add stop
|
|
797
|
+
</TextLink>
|
|
798
|
+
</View>
|
|
799
|
+
</View>
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** A party ROW — a reference when one is attached, the find-or-create picker when
|
|
804
|
+
* not. The two states share the row so the field never moves; the picker is the
|
|
805
|
+
* field's EMPTY state, not a different kind of surface. */
|
|
806
|
+
function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }: {
|
|
807
|
+
role: string;
|
|
808
|
+
rec: Customer | null;
|
|
809
|
+
options: PickerOption<string, Customer>[];
|
|
810
|
+
placeholder: string;
|
|
811
|
+
onPick: (opt: PickerOption<string, Customer>) => void;
|
|
812
|
+
onOpen: () => void;
|
|
813
|
+
onRemove: () => void;
|
|
814
|
+
}) {
|
|
815
|
+
return (
|
|
816
|
+
<DetailRow label={role}>
|
|
817
|
+
{rec ? (
|
|
818
|
+
<ReferenceValue
|
|
819
|
+
name={rec.name}
|
|
820
|
+
code={rec.code}
|
|
821
|
+
facts={[
|
|
822
|
+
{ label: "Contact", value: rec.contact },
|
|
823
|
+
{ label: "City", value: rec.city },
|
|
824
|
+
{ label: "Tax ID", value: rec.taxId },
|
|
825
|
+
]}
|
|
826
|
+
accessibilityLabel={`${rec.name} — details`}
|
|
827
|
+
openLabel={`Open ${rec.name}`}
|
|
828
|
+
onOpen={onOpen}
|
|
829
|
+
onRemove={onRemove}
|
|
830
|
+
/>
|
|
831
|
+
) : (
|
|
832
|
+
<Combobox options={options} onValueChange={onPick} reflectSelection={false}>
|
|
833
|
+
<ComboboxInput icon="search" placeholder={placeholder} accessibilityLabel={role} />
|
|
834
|
+
<ComboboxContent emptyText="No match" />
|
|
835
|
+
</Combobox>
|
|
836
|
+
)}
|
|
837
|
+
</DetailRow>
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
659
841
|
export function TplRecord() {
|
|
660
842
|
const id = useRef(1);
|
|
661
843
|
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
@@ -688,52 +870,6 @@ export function TplRecord() {
|
|
|
688
870
|
// ── the audit trail — real actions on this surface append to it. An entry
|
|
689
871
|
// DRILLS DOWN via the Timeline `details` chevron: a field change carries its
|
|
690
872
|
// From → To diff, a document entry the pressable file, an issue its amount.
|
|
691
|
-
const [activity, setActivity] = useState<TimelineItem[]>(() => [
|
|
692
|
-
{
|
|
693
|
-
id: "ac_4", icon: "calendar", iconColor: colors.zinc[500],
|
|
694
|
-
label: "Due date changed — Sarah Chen",
|
|
695
|
-
description: "Pulled in a week after the customer confirmed the window.",
|
|
696
|
-
right: <Text size="xs" color="muted">16 Jun, 10:02 AM</Text>,
|
|
697
|
-
details: (
|
|
698
|
-
<DetailTable labelWidth={80}>
|
|
699
|
-
<DetailRow label="From"><InlineStatic value="06/29/2026" tabular /></DetailRow>
|
|
700
|
-
<DetailRow label="To"><InlineStatic value="06/22/2026" tabular /></DetailRow>
|
|
701
|
-
</DetailTable>
|
|
702
|
-
),
|
|
703
|
-
},
|
|
704
|
-
{
|
|
705
|
-
id: "ac_3", icon: "file-text", iconColor: colors.zinc[500],
|
|
706
|
-
label: "booking-confirmation.pdf added — David Park",
|
|
707
|
-
right: <Text size="xs" color="muted">16 Jun, 9:20 AM</Text>,
|
|
708
|
-
details: (
|
|
709
|
-
<FileRow
|
|
710
|
-
name="booking-confirmation.pdf"
|
|
711
|
-
mimeType="application/pdf"
|
|
712
|
-
onPress={() => setPreview({ files: [{ id: "ac_f1", filename: "booking-confirmation.pdf", mimeType: "application/pdf", url: MOCK_PDF_URL }], index: 0 })}
|
|
713
|
-
/>
|
|
714
|
-
),
|
|
715
|
-
},
|
|
716
|
-
{ 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> },
|
|
717
|
-
{ 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> },
|
|
718
|
-
]);
|
|
719
|
-
const logActivity = (
|
|
720
|
-
icon: TimelineItem["icon"],
|
|
721
|
-
label: string,
|
|
722
|
-
extra?: { iconColor?: string; description?: string; details?: ReactNode },
|
|
723
|
-
) =>
|
|
724
|
-
setActivity((prev) => [
|
|
725
|
-
{
|
|
726
|
-
id: `ac_${prev.length + 1}`,
|
|
727
|
-
icon,
|
|
728
|
-
iconColor: extra?.iconColor ?? colors.zinc[500],
|
|
729
|
-
label,
|
|
730
|
-
description: extra?.description,
|
|
731
|
-
details: extra?.details,
|
|
732
|
-
right: <Text size="xs" color="muted">{stamp(new Date())}</Text>,
|
|
733
|
-
},
|
|
734
|
-
...prev,
|
|
735
|
-
]);
|
|
736
|
-
|
|
737
873
|
// ── the fees ledger — register + entity drawer (create-then-refine: Add
|
|
738
874
|
// creates a blank fee and opens it; every field refines in the drawer)
|
|
739
875
|
const [fees, setFees] = useState<Fee[]>(FEE_SEED);
|
|
@@ -743,17 +879,16 @@ export function TplRecord() {
|
|
|
743
879
|
const f: Fee = { id: `fee_${(feeSeq += 1)}`, name: "", direction: "cost", party: "", amount: 0, vat: null, due: "", invoiceNo: "", paid: false, note: "" };
|
|
744
880
|
setFees((prev) => [...prev, f]);
|
|
745
881
|
setFeeView({ kind: "edit", id: f.id });
|
|
746
|
-
logActivity("receipt", "Fee added");
|
|
747
882
|
};
|
|
748
883
|
const openFee = feeView ? fees.find((f) => f.id === feeView.id) ?? null : null;
|
|
749
884
|
const feeIdx = openFee ? fees.findIndex((f) => f.id === openFee.id) : -1;
|
|
750
885
|
const deleteFee = (f: Fee) => {
|
|
751
886
|
Alert.alert(`Remove ${f.name || "this fee"}?`, "The fee comes off the record's ledger. This can't be undone.", [
|
|
752
887
|
{ text: "Cancel", style: "cancel" },
|
|
753
|
-
{ text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null);
|
|
888
|
+
{ text: "Remove", style: "destructive", onPress: () => { setFees((prev) => prev.filter((x) => x.id !== f.id)); setFeeView(null); } },
|
|
754
889
|
]);
|
|
755
890
|
};
|
|
756
|
-
// The status column speaks in facts: Paid
|
|
891
|
+
// The status column speaks in facts: Paid, Overdue, Due <date>, Unpaid.
|
|
757
892
|
const feeStatus = (f: Fee): { text: string; danger: boolean } =>
|
|
758
893
|
f.paid ? { text: "Paid", danger: false }
|
|
759
894
|
: feeOverdue(f) ? { text: "Overdue", danger: true }
|
|
@@ -778,6 +913,37 @@ export function TplRecord() {
|
|
|
778
913
|
const [reference, setReference] = useState("");
|
|
779
914
|
const [weight, setWeight] = useState<number | null>(1250);
|
|
780
915
|
|
|
916
|
+
// ── TRANSPORT — a DENSE section, faithfully dense on purpose: two groups of
|
|
917
|
+
// ~10 fields each, mixed editor types, two record references, a derived
|
|
918
|
+
// warning, and one "Show more" expansion. This is the shape a real operations
|
|
919
|
+
// record arrives at, and the surface every compaction idea has to be judged
|
|
920
|
+
// against — measure any change here, not on a four-field group.
|
|
921
|
+
const [shipTo, setShipTo] = useState<string | null>("cus_03");
|
|
922
|
+
const [notify, setNotify] = useState<string | null>(null);
|
|
923
|
+
const [carrier, setCarrier] = useState<string | null>("cus_05");
|
|
924
|
+
const [docType, setDocType] = useState("original");
|
|
925
|
+
const [waybill, setWaybill] = useState("WB-4471902");
|
|
926
|
+
const [orderRef, setOrderRef] = useState("");
|
|
927
|
+
const [copies, setCopies] = useState<number | null>(3);
|
|
928
|
+
const [incoterm, setIncoterm] = useState<string | null>("dap");
|
|
929
|
+
const [chargeTerms, setChargeTerms] = useState("prepaid");
|
|
930
|
+
const [booking, setBooking] = useState("BK-208814");
|
|
931
|
+
const [vehicle, setVehicle] = useState("");
|
|
932
|
+
const [trip, setTrip] = useState("");
|
|
933
|
+
// A ROUTE IS A SEQUENCE, so it is state shaped like one — not an origin field,
|
|
934
|
+
// a transfer field and a destination field, which has nowhere to put a SECOND
|
|
935
|
+
// transfer and cannot say that the stops are ordered.
|
|
936
|
+
const [stops, setStops] = useState<Stop[]>([
|
|
937
|
+
{ id: "st_1", place: "Rotterdam", eta: "2026-06-18" },
|
|
938
|
+
{ id: "st_2", place: "Antwerp", eta: "2026-06-20" },
|
|
939
|
+
{ id: "st_3", place: "Hamburg", eta: "2026-06-24" },
|
|
940
|
+
]);
|
|
941
|
+
const [dropPoint, setDropPoint] = useState("");
|
|
942
|
+
const [scheduleNote, setScheduleNote] = useState("delayed");
|
|
943
|
+
const shipToRec = customers.find((c) => c.id === shipTo) ?? null;
|
|
944
|
+
const notifyRec = customers.find((c) => c.id === notify) ?? null;
|
|
945
|
+
const carrierRec = customers.find((c) => c.id === carrier) ?? null;
|
|
946
|
+
|
|
781
947
|
// ── customer section — TWO states: attached (read-only card, Remove
|
|
782
948
|
// detaches) or empty (the find-or-create search). The "create" branch opens
|
|
783
949
|
// a focused Dialog; non-null draft = the dialog is open.
|
|
@@ -871,10 +1037,8 @@ export function TplRecord() {
|
|
|
871
1037
|
// (openExternal(file.url)).
|
|
872
1038
|
// One sort at a time; the third toggle on a column clears it (register law).
|
|
873
1039
|
const [docSort, setDocSort] = useState<SortState | null>(null);
|
|
874
|
-
const [docSearch, setDocSearch] = useState("");
|
|
875
|
-
const docQuery = docSearch.trim().toLowerCase();
|
|
876
1040
|
const visibleFiles = sortBy(
|
|
877
|
-
|
|
1041
|
+
files,
|
|
878
1042
|
docSort,
|
|
879
1043
|
(f, key) => (key === "doc" ? f.name.toLowerCase() : key === "size" ? f.sizeKB : f.addedAt),
|
|
880
1044
|
);
|
|
@@ -1034,7 +1198,6 @@ export function TplRecord() {
|
|
|
1034
1198
|
setTimeout(() => {
|
|
1035
1199
|
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 })));
|
|
1036
1200
|
setProducing(false);
|
|
1037
|
-
logActivity("file-stack", `Document set created — ${chosen.length} ${chosen.length === 1 ? "form" : "forms"}`, { description: chosen.map((f) => f.label).join(", ") });
|
|
1038
1201
|
}, 900);
|
|
1039
1202
|
};
|
|
1040
1203
|
// Missing fields DEGRADE the outcome (those fields print blank, filled by
|
|
@@ -1074,7 +1237,6 @@ export function TplRecord() {
|
|
|
1074
1237
|
setTimeout(() => {
|
|
1075
1238
|
setRcptDoc({ id: "rcpt-1", name: `delivery-receipt_${code}.pdf`, mimeType: "application/pdf", kind: "PDF", sizeKB: 64, added: "just now", addedAt: 999 });
|
|
1076
1239
|
setRcptProducing(false);
|
|
1077
|
-
logActivity("file-text", "Delivery receipt created — RCPT-2026-0715");
|
|
1078
1240
|
}, 900);
|
|
1079
1241
|
};
|
|
1080
1242
|
|
|
@@ -1149,14 +1311,12 @@ export function TplRecord() {
|
|
|
1149
1311
|
const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
|
|
1150
1312
|
const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
|
|
1151
1313
|
const allMissing = invoices.flatMap(missingMethods);
|
|
1152
|
-
const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
|
|
1153
1314
|
|
|
1154
1315
|
const issue = (inv: Invoice) => {
|
|
1155
1316
|
seq.current += 1;
|
|
1156
1317
|
const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
|
|
1157
1318
|
setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
|
|
1158
1319
|
setConfirmIssueKey(null);
|
|
1159
|
-
logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
|
|
1160
1320
|
};
|
|
1161
1321
|
|
|
1162
1322
|
const printReceipt = () => {
|
|
@@ -1190,7 +1350,6 @@ export function TplRecord() {
|
|
|
1190
1350
|
: null;
|
|
1191
1351
|
|
|
1192
1352
|
// ── the handoff flow — the dialog's fields, the TRAIL of marks, and the
|
|
1193
|
-
// SIBLING records the handoffs created (shown as LinkedRecordCards)
|
|
1194
1353
|
const [handoffOpen, setHandoffOpen] = useState(false);
|
|
1195
1354
|
// The desk the OPEN dialog is handing to, captured on press. Not re-derived
|
|
1196
1355
|
// from `stage`: confirming advances the stage while the dialog is still fading
|
|
@@ -1198,41 +1357,16 @@ export function TplRecord() {
|
|
|
1198
1357
|
const [handoffTo, setHandoffTo] = useState<Desk>("operations");
|
|
1199
1358
|
const [handoffAssignee, setHandoffAssignee] = useState<string | null>(null);
|
|
1200
1359
|
const [handoffNote, setHandoffNote] = useState("");
|
|
1201
|
-
const [handoffs, setHandoffs] = useState<TimelineItem[]>([]);
|
|
1202
|
-
const [siblings, setSiblings] = useState<SiblingRecord[]>([]);
|
|
1203
|
-
const [siblingOpen, setSiblingOpen] = useState<string | null>(null);
|
|
1204
|
-
const openSibling = siblingOpen ? siblings.find((sb) => sb.id === siblingOpen) ?? null : null;
|
|
1205
1360
|
// ── the customer detail drawer (the linked record's full info)
|
|
1206
1361
|
const [customerOpen, setCustomerOpen] = useState(false);
|
|
1207
|
-
// Confirm MARKS the handoff — whom
|
|
1208
|
-
// CREATES the next desk's record on ITS table, linked here. The mark shows
|
|
1209
|
-
// that linked sibling as a reference; its summary lives on the sibling.
|
|
1362
|
+
// Confirm MARKS the handoff — whom, where, the note.
|
|
1210
1363
|
//
|
|
1211
1364
|
// It commits what the DIALOG captured (`handoffTo`), never what `stage` implies
|
|
1212
1365
|
// right now: the dialog is a statement about one handoff, and re-deriving the
|
|
1213
1366
|
// target at confirm time would give two sources for the same fact.
|
|
1214
1367
|
const confirmHandoff = () => {
|
|
1215
1368
|
if (handoffAssignee == null) return;
|
|
1216
|
-
const to = TEAM.find((m) => m.id === handoffAssignee);
|
|
1217
1369
|
const at = new Date();
|
|
1218
|
-
const deskName = stageOf(handoffTo).label;
|
|
1219
|
-
const siblingCode = `${handoffTo === "operations" ? "OP" : "AC"}-2026-00${(siblingSeq += 1)}`;
|
|
1220
|
-
setSiblings((prev) => [
|
|
1221
|
-
{ id: siblingCode, code: siblingCode, deskKey: handoffTo, desk: deskName, assignee: to?.name ?? "", note: handoffNote.trim(), at: stamp(at) },
|
|
1222
|
-
...prev,
|
|
1223
|
-
]);
|
|
1224
|
-
setHandoffs((prev) => [
|
|
1225
|
-
{
|
|
1226
|
-
id: `h_${prev.length + 1}`,
|
|
1227
|
-
icon: "send",
|
|
1228
|
-
iconColor: colors.blue[500],
|
|
1229
|
-
label: `Handed off to ${deskName} — ${to?.name ?? ""}`,
|
|
1230
|
-
description: handoffNote.trim() || undefined,
|
|
1231
|
-
right: <Text size="xs" color="muted">{stamp(at)}</Text>,
|
|
1232
|
-
},
|
|
1233
|
-
...prev,
|
|
1234
|
-
]);
|
|
1235
|
-
logActivity("send", `Handed off to ${deskName} — ${to?.name ?? ""}`, { iconColor: colors.blue[500], description: `${siblingCode} created and linked` });
|
|
1236
1370
|
// The handoff STAMPS the desk it opens — the dialog already asked whom and
|
|
1237
1371
|
// it is happening now, so the receiving stage's own fields must not come up
|
|
1238
1372
|
// blank and re-ask. They stay editable there; this is the default, not a lock.
|
|
@@ -1252,80 +1386,49 @@ export function TplRecord() {
|
|
|
1252
1386
|
{
|
|
1253
1387
|
text: "Close record",
|
|
1254
1388
|
onPress: () => {
|
|
1255
|
-
logActivity("circle-check", "Record closed", { iconColor: colors.emerald[500] });
|
|
1256
1389
|
setStage("closed");
|
|
1257
1390
|
},
|
|
1258
1391
|
},
|
|
1259
1392
|
]);
|
|
1260
1393
|
};
|
|
1261
|
-
// RECALL — the undo: withdraws the sibling (a real app cancels the sibling
|
|
1262
|
-
// record, and gates recall on it being UNTOUCHED at its desk) and returns
|
|
1263
|
-
// the desk. History is never erased — the trail keeps the original mark and
|
|
1264
|
-
// gains a recall mark; the handoff CTA comes back, so redo is possible.
|
|
1265
|
-
//
|
|
1266
|
-
// It undoes exactly ONE step: back to the desk BEFORE the one this handoff
|
|
1267
|
-
// opened, and it clears that desk's custody stamp. Rewinding to the first desk
|
|
1268
|
-
// instead would silently drop the desks in between, and leaving the stamp
|
|
1269
|
-
// behind would leave `stage` and the custody map disagreeing — an unreached
|
|
1270
|
-
// desk still holding a taken-on date and an owner.
|
|
1271
|
-
const recallHandoff = (sb: SiblingRecord) => {
|
|
1272
|
-
const backTo = DESKS[DESKS.findIndex((d) => d.key === sb.deskKey) - 1]?.key ?? "sales";
|
|
1273
|
-
Alert.alert(`Recall the handoff?`, `${sb.code} is withdrawn from ${sb.desk} and the record returns to ${stageOf(backTo).label}. The trail keeps both marks.`, [
|
|
1274
|
-
{ text: "Cancel", style: "cancel" },
|
|
1275
|
-
{
|
|
1276
|
-
text: "Recall",
|
|
1277
|
-
style: "destructive",
|
|
1278
|
-
onPress: () => {
|
|
1279
|
-
setSiblings((prev) => prev.filter((x) => x.id !== sb.id));
|
|
1280
|
-
setSiblingOpen(null);
|
|
1281
|
-
setDeskSince((p) => ({ ...p, [sb.deskKey]: "" }));
|
|
1282
|
-
setDeskOwner((p) => ({ ...p, [sb.deskKey]: null }));
|
|
1283
|
-
setStage(backTo);
|
|
1284
|
-
setHandoffs((prev) => [
|
|
1285
|
-
{ 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> },
|
|
1286
|
-
...prev,
|
|
1287
|
-
]);
|
|
1288
|
-
logActivity("x", `Handoff recalled — ${sb.code} withdrawn from ${sb.desk}`);
|
|
1289
|
-
},
|
|
1290
|
-
},
|
|
1291
|
-
]);
|
|
1292
|
-
};
|
|
1293
|
-
|
|
1294
1394
|
// ── the outline rail — jump to a section instead of scrolling the long page
|
|
1295
|
-
// GENERAL leads the rail — the item's core details
|
|
1296
|
-
//
|
|
1395
|
+
// GENERAL leads the rail — the item's core details (the customer among them,
|
|
1396
|
+
// since a reference is a field); everything after it is supplementary
|
|
1397
|
+
// (discussion, work, documents, money, lifecycle).
|
|
1297
1398
|
const SECTIONS = [
|
|
1298
1399
|
{ key: "general", label: "General", icon: "file-text" },
|
|
1299
|
-
{ key: "
|
|
1400
|
+
{ key: "transport", label: "Transport", icon: "map-pin" },
|
|
1300
1401
|
{ key: "progress", label: "Progress", icon: "git-branch" },
|
|
1301
1402
|
{ key: "documents", label: "Documents", icon: "folder-closed" },
|
|
1302
|
-
{ key: "customer", label: "Customer", icon: "building-2" },
|
|
1303
1403
|
{ key: "fees", label: "Fees", icon: "receipt" },
|
|
1304
1404
|
{ key: "billing", label: "Billing", icon: "credit-card" },
|
|
1305
1405
|
{ key: "docset", label: "Document set", icon: "file-stack" },
|
|
1306
1406
|
{ key: "receipt", label: "Delivery receipt", icon: "log-in" },
|
|
1307
|
-
{ key: "activity", label: "Activity", icon: "clock" },
|
|
1308
|
-
{ key: "handoff", label: "Handoff", icon: "send" },
|
|
1309
1407
|
{ key: "danger", label: "Danger zone", icon: "circle-alert" },
|
|
1310
1408
|
] as const;
|
|
1311
|
-
const nav = useSectionNav(["general", "
|
|
1409
|
+
const nav = useSectionNav(["general", "transport", "progress", "documents", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
1312
1410
|
// Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
|
|
1313
1411
|
// it opens a full-page section picker (Escape / the close control dismiss).
|
|
1314
1412
|
const [sectionsOpen, setSectionsOpen] = useState(false);
|
|
1315
1413
|
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
const wide = pageWidth != null && pageWidth >= GUTTER
|
|
1414
|
+
// THREE COLUMNS: the outline rail in the left gutter, the record, and the
|
|
1415
|
+
// discussion panel in the right. The right gutter used to be empty — reserved
|
|
1416
|
+
// only to centre the column — so the panel costs no width the page did not
|
|
1417
|
+
// already spend. Both only earn their place while a readable column survives
|
|
1418
|
+
// between them; below that they collapse to the pinned bar and its drawer.
|
|
1419
|
+
const wide = pageWidth != null && pageWidth >= GUTTER + PANEL_GUTTER + 560;
|
|
1420
|
+
// Narrow: the discussion is one tap off the same bar the sections are on —
|
|
1421
|
+
// on the RIGHT, opposite the section picker, since it is about the record as a
|
|
1422
|
+
// whole rather than a place in it.
|
|
1423
|
+
const [commentsOpen, setCommentsOpen] = useState(false);
|
|
1322
1424
|
// The rail floats over its RESERVED gutter rather than sitting in the scroll
|
|
1323
1425
|
// row, so the ScrollView keeps the FULL page width — wheel anywhere and the
|
|
1324
1426
|
// record scrolls. (Capping the ScrollView at the column's width instead makes
|
|
1325
1427
|
// the gutters dead zones.) Mirrors the flex maths the row does below: the
|
|
1326
1428
|
// group is centred inside the 28px content padding.
|
|
1327
|
-
const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER
|
|
1429
|
+
const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER + CONTENT_MAX + PANEL_GUTTER);
|
|
1328
1430
|
const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
|
|
1431
|
+
const panelRight = railLeft;
|
|
1329
1432
|
|
|
1330
1433
|
return (
|
|
1331
1434
|
// Measure-then-reveal: hidden until the width is known, so the rail/bar
|
|
@@ -1345,6 +1448,16 @@ export function TplRecord() {
|
|
|
1345
1448
|
icon="list-collapse"
|
|
1346
1449
|
onPress={() => setSectionsOpen(true)}
|
|
1347
1450
|
/>
|
|
1451
|
+
<View style={{ flex: 1 }} />
|
|
1452
|
+
{/* WHERE it sits carries the meaning: sections on the left (where you
|
|
1453
|
+
ARE), the discussion on the right (what is being SAID) — the same
|
|
1454
|
+
two poles the wide layout puts in its two gutters. */}
|
|
1455
|
+
<IconButton
|
|
1456
|
+
icon="message-square"
|
|
1457
|
+
tooltip="Comments"
|
|
1458
|
+
accessibilityLabel={`Comments (${comments.length})`}
|
|
1459
|
+
onPress={() => setCommentsOpen(true)}
|
|
1460
|
+
/>
|
|
1348
1461
|
</View>
|
|
1349
1462
|
)}
|
|
1350
1463
|
<View style={{ flex: 1 }}>
|
|
@@ -1354,7 +1467,7 @@ export function TplRecord() {
|
|
|
1354
1467
|
and an equal empty gutter that balances it — so the column's centre is
|
|
1355
1468
|
the window's centre. Both gutters live INSIDE the scroller, so they
|
|
1356
1469
|
scroll like everything else. */}
|
|
1357
|
-
<View style={{ width: "100%", maxWidth: wide ? GUTTER
|
|
1470
|
+
<View style={{ width: "100%", maxWidth: wide ? GUTTER + CONTENT_MAX + PANEL_GUTTER : CONTENT_MAX, alignSelf: "center", flexDirection: "row" }}>
|
|
1358
1471
|
{wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
|
|
1359
1472
|
{booting ? (
|
|
1360
1473
|
/* first paint — Skeleton MIRRORS the final layout (summary →
|
|
@@ -1388,14 +1501,14 @@ export function TplRecord() {
|
|
|
1388
1501
|
hairline BETWEEN top-level blocks (a conditional block that renders
|
|
1389
1502
|
null never leaves a stray divider) */}
|
|
1390
1503
|
<SectionStack style={{ flex: 1, minWidth: 0 }}>
|
|
1391
|
-
{/* header band — identity
|
|
1504
|
+
{/* header band — identity, attention, a light summary. No stage chip
|
|
1392
1505
|
here (Progress carries the desk and the act), no breadcrumb,
|
|
1393
1506
|
no create CTA: back lives in the panel, creation belongs to the
|
|
1394
1507
|
REGISTER (the list owns "new"), the record page only edits. */}
|
|
1395
1508
|
<View style={{ gap: 16 }}>
|
|
1396
1509
|
<RecordSummary
|
|
1397
1510
|
title={code}
|
|
1398
|
-
subtitle={customer ? [customer.name, customer.city].filter(Boolean).join("
|
|
1511
|
+
subtitle={customer ? [customer.name, customer.city].filter(Boolean).join(", ") : "No customer"}
|
|
1399
1512
|
/>
|
|
1400
1513
|
{/* the record's ATTENTION state, on top — record-scoped, so it reads
|
|
1401
1514
|
as a Callout here (field-scoped state stays on its field) */}
|
|
@@ -1419,7 +1532,7 @@ export function TplRecord() {
|
|
|
1419
1532
|
SubsectionStack (space-only beat): a headingless LEAD group (the
|
|
1420
1533
|
facts every desk reads first) then the named groups — the rail
|
|
1421
1534
|
stays sections-only, grouping lives INSIDE the section. Every
|
|
1422
|
-
group's DetailTable repeats the SAME labelWidth
|
|
1535
|
+
group's DetailTable repeats the SAME labelWidth, so
|
|
1423
1536
|
the grid stays aligned through the groups; read-only values are
|
|
1424
1537
|
InlineStatic in the same column, aligned to the pixel. */}
|
|
1425
1538
|
<View onLayout={nav.register("general")}>
|
|
@@ -1429,7 +1542,66 @@ export function TplRecord() {
|
|
|
1429
1542
|
</SectionHeading>
|
|
1430
1543
|
<SubsectionStack>
|
|
1431
1544
|
<Subsection>
|
|
1432
|
-
<DetailTable labelWidth={150}
|
|
1545
|
+
<DetailTable labelWidth={150}>
|
|
1546
|
+
{/* A REFERENCE TO ANOTHER RECORD IS A FIELD, NOT A SECTION.
|
|
1547
|
+
A section is a claim that a distinct AREA of the record lives
|
|
1548
|
+
here, and it buys an outline-rail entry and a heading; one
|
|
1549
|
+
pointer to one other row never earns that. The machinery that
|
|
1550
|
+
tempts you to promote it — a find-or-create search, a create
|
|
1551
|
+
dialog, a validity warning — is just this field's EMPTY state,
|
|
1552
|
+
its picker, and its annotation. So it sits among the order's
|
|
1553
|
+
other core facts, where a reader looks for the bill-to party.
|
|
1554
|
+
|
|
1555
|
+
The value is a `ReferenceValue` (above): the inline editor's
|
|
1556
|
+
own resting surface, so the reference sits in the value column
|
|
1557
|
+
like every editor around it rather than as a card that
|
|
1558
|
+
outweighs the fields it sits among. Its three verbs are three
|
|
1559
|
+
targets — press for the facts, the arrow for the record,
|
|
1560
|
+
Remove in the row's `trailing` where row-level verbs live. */}
|
|
1561
|
+
<DetailRow
|
|
1562
|
+
label="Customer"
|
|
1563
|
+
description="The bill-to party — its tax ID gates invoicing below."
|
|
1564
|
+
warning={customer && !taxIdValid ? "No valid tax ID — invoicing stays blocked until this customer carries one." : undefined}
|
|
1565
|
+
>
|
|
1566
|
+
{customer ? (
|
|
1567
|
+
<ReferenceValue
|
|
1568
|
+
name={customer.name}
|
|
1569
|
+
code={customer.code}
|
|
1570
|
+
facts={[
|
|
1571
|
+
{ label: "Tax ID", value: customer.taxId },
|
|
1572
|
+
{ label: "Contact", value: customer.contact },
|
|
1573
|
+
{ label: "City", value: customer.city },
|
|
1574
|
+
]}
|
|
1575
|
+
accessibilityLabel={`${customer.name} — details`}
|
|
1576
|
+
openLabel="Open customer"
|
|
1577
|
+
onOpen={() => setCustomerOpen(true)}
|
|
1578
|
+
onRemove={() => setCustomerId(null)}
|
|
1579
|
+
/>
|
|
1580
|
+
) : (
|
|
1581
|
+
<Combobox
|
|
1582
|
+
options={customerOptions}
|
|
1583
|
+
onValueChange={onPickCustomer}
|
|
1584
|
+
renderOptionContent={(o) => (
|
|
1585
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
1586
|
+
<View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
|
|
1587
|
+
<Icon name="building-2" size={14} color={colors.zinc[600]} />
|
|
1588
|
+
</View>
|
|
1589
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
1590
|
+
<Text size="sm" numberOfLines={1}>{o.label}</Text>
|
|
1591
|
+
{o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(", ")}</Text> : null}
|
|
1592
|
+
</View>
|
|
1593
|
+
</View>
|
|
1594
|
+
)}
|
|
1595
|
+
reflectSelection={false}
|
|
1596
|
+
allowCustom
|
|
1597
|
+
customOptionPlacement="top"
|
|
1598
|
+
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
1599
|
+
>
|
|
1600
|
+
<ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
|
|
1601
|
+
<ComboboxContent emptyText="No customer matches — type a name to create one" />
|
|
1602
|
+
</Combobox>
|
|
1603
|
+
)}
|
|
1604
|
+
</DetailRow>
|
|
1433
1605
|
{/* The SAME fact the Sales stage owns in Progress, shown where
|
|
1434
1606
|
a reader looks for it first — bound to the one source, never
|
|
1435
1607
|
a second copy: two member selects over their own state would
|
|
@@ -1460,12 +1632,27 @@ export function TplRecord() {
|
|
|
1460
1632
|
<SubsectionHeading>
|
|
1461
1633
|
<SubsectionHeadingTitle>Order</SubsectionHeadingTitle>
|
|
1462
1634
|
</SubsectionHeading>
|
|
1463
|
-
<DetailTable labelWidth={150}
|
|
1635
|
+
<DetailTable labelWidth={150}>
|
|
1464
1636
|
{/* a persistent field DESCRIPTION — under the value, same
|
|
1465
1637
|
vocabulary as FormField; state → `error` / a co-located
|
|
1466
1638
|
Callout. Everything EXPLICIT — no hidden ⓘ gloss. */}
|
|
1467
|
-
|
|
1468
|
-
|
|
1639
|
+
{/* Copy lives ON the field, not in the row's trailing column: it
|
|
1640
|
+
acts on THIS value, so it travels with it, and a reader never
|
|
1641
|
+
has to pair a control at the page's right edge with the field
|
|
1642
|
+
it belongs to. `InlineButton` + the `actions` slot own the
|
|
1643
|
+
anatomy that makes a control inside a press target legal. */}
|
|
1644
|
+
<DetailRow label="Reference" description="Printed on every document">
|
|
1645
|
+
<InlineTextInput
|
|
1646
|
+
value={reference}
|
|
1647
|
+
onSave={persist(setReference)}
|
|
1648
|
+
placeholder="Add buyer's reference…"
|
|
1649
|
+
accessibilityLabel="Reference"
|
|
1650
|
+
// UNCONDITIONAL, disabled when there is nothing to copy — a
|
|
1651
|
+
// verb that appears once the field is non-empty changes the
|
|
1652
|
+
// field's width as you type, which is the one thing an inline
|
|
1653
|
+
// editor promises never to do.
|
|
1654
|
+
actions={<InlineButton title="Copy" accessibilityLabel="Copy reference" disabled={!reference} onPress={() => {}} />}
|
|
1655
|
+
/>
|
|
1469
1656
|
</DetailRow>
|
|
1470
1657
|
<DetailRow label="Priority">
|
|
1471
1658
|
<InlineSelect
|
|
@@ -1487,7 +1674,7 @@ export function TplRecord() {
|
|
|
1487
1674
|
<SubsectionHeading>
|
|
1488
1675
|
<SubsectionHeadingTitle>Fulfilment</SubsectionHeadingTitle>
|
|
1489
1676
|
</SubsectionHeading>
|
|
1490
|
-
<DetailTable labelWidth={150}
|
|
1677
|
+
<DetailTable labelWidth={150}>
|
|
1491
1678
|
<DetailRow label="Location" description="The site that picks and ships the order">
|
|
1492
1679
|
<InlineSelect value={warehouse} onSave={persist(setWarehouse)} options={WAREHOUSES} accessibilityLabel="Location" />
|
|
1493
1680
|
</DetailRow>
|
|
@@ -1516,15 +1703,15 @@ export function TplRecord() {
|
|
|
1516
1703
|
</Subsection>
|
|
1517
1704
|
{/* CLASSIFICATION — the right-input-per-field law, worked. A field
|
|
1518
1705
|
gets the control its SHAPE wants, not a default text box:
|
|
1519
|
-
|
|
1706
|
+
, ≤5 exclusive options the user should SEE → RadioPicker
|
|
1520
1707
|
(a stacked column of full-width choices)
|
|
1521
|
-
|
|
1708
|
+
, a longer closed list → InlineSelect (rich options carry a
|
|
1522
1709
|
description line — it shows in the resting row too)
|
|
1523
|
-
|
|
1710
|
+
, a pick from a big REGISTRY → InlineSelect searchable (the
|
|
1524
1711
|
search lives in the popover; the bare Combobox is the FORM-
|
|
1525
1712
|
surface control — find-or-create / attach flows)
|
|
1526
|
-
|
|
1527
|
-
|
|
1713
|
+
, a boolean → CheckboxInput (Hazardous goods below)
|
|
1714
|
+
, a DEPENDENT field renders only while its parent value makes
|
|
1528
1715
|
it real — no disabled ghost rows.
|
|
1529
1716
|
Radio/checkbox/search are PERSISTENT controls (the control is
|
|
1530
1717
|
the best display); prose-shaped values stay inline editors. */}
|
|
@@ -1532,19 +1719,25 @@ export function TplRecord() {
|
|
|
1532
1719
|
<SubsectionHeading>
|
|
1533
1720
|
<SubsectionHeadingTitle>Classification</SubsectionHeadingTitle>
|
|
1534
1721
|
</SubsectionHeading>
|
|
1535
|
-
<DetailTable labelWidth={150}
|
|
1722
|
+
<DetailTable labelWidth={150}>
|
|
1536
1723
|
<DetailRow label="Service level">
|
|
1537
1724
|
<InlineSelect
|
|
1538
1725
|
value={serviceLevel}
|
|
1539
1726
|
options={SERVICE_LEVELS}
|
|
1540
1727
|
onSave={persist(setServiceLevel)}
|
|
1541
1728
|
accessibilityLabel="Service level"
|
|
1729
|
+
/* The gloss belongs to CHOOSING, not to every later read.
|
|
1730
|
+
Without `renderSelected` a rich option renders the resting
|
|
1731
|
+
row too, so the field stands two lines tall among
|
|
1732
|
+
one-line neighbours — row-HEIGHT variation being what
|
|
1733
|
+
actually reads as clutter on a field grid. */
|
|
1542
1734
|
renderOptionContent={(o) => (
|
|
1543
1735
|
<View style={{ gap: 1, flexShrink: 1 }}>
|
|
1544
1736
|
<Text size="sm">{o.label}</Text>
|
|
1545
1737
|
<Text size="xs" color="muted">{SERVICE_LEVEL_DESC[o.value]}</Text>
|
|
1546
1738
|
</View>
|
|
1547
1739
|
)}
|
|
1740
|
+
renderSelected={(o) => <Text size="sm">{o.label}</Text>}
|
|
1548
1741
|
/>
|
|
1549
1742
|
</DetailRow>
|
|
1550
1743
|
<DetailRow label="Fulfilment status">
|
|
@@ -1601,6 +1794,7 @@ export function TplRecord() {
|
|
|
1601
1794
|
{o.data ? <Text size="xs" color="muted">{o.data.country}</Text> : null}
|
|
1602
1795
|
</View>
|
|
1603
1796
|
)}
|
|
1797
|
+
renderSelected={(o) => <Text size="sm">{o.label}</Text>}
|
|
1604
1798
|
/>
|
|
1605
1799
|
</DetailRow>
|
|
1606
1800
|
{/* fields the OUTPUT FORMS read — each description names its
|
|
@@ -1625,7 +1819,7 @@ export function TplRecord() {
|
|
|
1625
1819
|
<SubsectionHeading>
|
|
1626
1820
|
<SubsectionHeadingTitle>System</SubsectionHeadingTitle>
|
|
1627
1821
|
</SubsectionHeading>
|
|
1628
|
-
<DetailTable labelWidth={150}
|
|
1822
|
+
<DetailTable labelWidth={150}>
|
|
1629
1823
|
<DetailRow label="Record ID">
|
|
1630
1824
|
<InlineStatic value={code} tabular />
|
|
1631
1825
|
</DetailRow>
|
|
@@ -1635,55 +1829,167 @@ export function TplRecord() {
|
|
|
1635
1829
|
</Section>
|
|
1636
1830
|
</View>
|
|
1637
1831
|
|
|
1638
|
-
{/*
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1832
|
+
{/* TRANSPORT — the DENSE section, and the honest one. Two groups of ~10
|
|
1833
|
+
fields, three record references, mixed editor types, a derived
|
|
1834
|
+
warning, one "Show more".
|
|
1835
|
+
|
|
1836
|
+
It is here UNCOMPACTED on purpose. A real operations record reaches
|
|
1837
|
+
this density — the movement's paperwork, its parties, its route — and
|
|
1838
|
+
every idea for making a record readable has to be judged against a
|
|
1839
|
+
surface like this one, never against a four-field group where
|
|
1840
|
+
everything already fits. Read it as the BEFORE. */}
|
|
1841
|
+
<View onLayout={nav.register("transport")}>
|
|
1644
1842
|
<Section>
|
|
1645
1843
|
<SectionHeading>
|
|
1646
|
-
<SectionHeadingTitle description="The
|
|
1844
|
+
<SectionHeadingTitle description="The consignment document, the parties printed on it, the carrier and the route.">Transport</SectionHeadingTitle>
|
|
1647
1845
|
</SectionHeading>
|
|
1648
|
-
<
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1846
|
+
<SubsectionStack>
|
|
1847
|
+
{/* The document and the two parties that PRINT on it — they live
|
|
1848
|
+
here, with its numbers and its type, because the document is the
|
|
1849
|
+
only place anyone reads them. */}
|
|
1850
|
+
<Subsection>
|
|
1851
|
+
<SubsectionHeading><SubsectionHeadingTitle>Consignment & terms</SubsectionHeadingTitle></SubsectionHeading>
|
|
1852
|
+
<DetailTable labelWidth={150} minHeight={40}>
|
|
1853
|
+
<PartyRow
|
|
1854
|
+
role="Ship to" rec={shipToRec} options={customerOptions}
|
|
1855
|
+
placeholder="Find or create a consignee"
|
|
1856
|
+
onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onRemove={() => setShipTo(null)}
|
|
1857
|
+
/>
|
|
1858
|
+
<PartyRow
|
|
1859
|
+
role="Notify" rec={notifyRec} options={customerOptions}
|
|
1860
|
+
placeholder="Find or create a notify party"
|
|
1861
|
+
onPick={(o) => setNotify(o.value)} onOpen={() => {}} onRemove={() => setNotify(null)}
|
|
1862
|
+
/>
|
|
1863
|
+
{/* A CHOICE IS ONE ROW, like every other field.
|
|
1864
|
+
`RadioPicker` renders three full-width bordered choices with
|
|
1865
|
+
their glosses — ~170px, four rows' worth of height, for one
|
|
1866
|
+
field — and that variation in WEIGHT between neighbouring rows
|
|
1867
|
+
is what makes a field grid read as cluttered, far more than
|
|
1868
|
+
the number of rows does. Radios earn their place where the
|
|
1869
|
+
choice IS the task (a wizard step, a short form); in a grid of
|
|
1870
|
+
nineteen fields they shout over everything around them.
|
|
1871
|
+
The gloss is not lost — it moves into the option LIST, where
|
|
1872
|
+
it is needed while CHOOSING rather than on every later read.
|
|
1873
|
+
That split is what `renderSelected` is for: without it the
|
|
1874
|
+
resting row falls back to `renderOptionContent` and prints the
|
|
1875
|
+
gloss on every row, which is the tall row again by another
|
|
1876
|
+
route. */}
|
|
1877
|
+
<DetailRow label="Document type">
|
|
1878
|
+
<InlineSelect
|
|
1879
|
+
value={docType}
|
|
1880
|
+
onSave={persist(setDocType)}
|
|
1881
|
+
options={DOC_TYPES}
|
|
1882
|
+
renderOptionContent={choiceWithHint}
|
|
1883
|
+
renderSelected={(o) => <Text size="sm">{o.label}</Text>}
|
|
1884
|
+
accessibilityLabel="Document type"
|
|
1885
|
+
/>
|
|
1886
|
+
</DetailRow>
|
|
1887
|
+
<DetailRow label="Waybill no.">
|
|
1888
|
+
<InlineTextInput value={waybill} onSave={persist(setWaybill)} placeholder="Add waybill number…" accessibilityLabel="Waybill number" />
|
|
1889
|
+
</DetailRow>
|
|
1890
|
+
<DetailRow label="Order no.">
|
|
1891
|
+
<InlineTextInput value={orderRef} onSave={persist(setOrderRef)} placeholder="Add order number…" accessibilityLabel="Order number" />
|
|
1892
|
+
</DetailRow>
|
|
1893
|
+
{/* NO DISCLOSURE. A "Show more" here hid three fields — and the
|
|
1894
|
+
fatal part is that it hid them whether or not they held a
|
|
1895
|
+
VALUE: a record with 3 original copies reported nothing about
|
|
1896
|
+
them until you found the link. A field grid's job is to say
|
|
1897
|
+
what the record IS, so an empty row (this record has no order
|
|
1898
|
+
number) and a filled one (it has 3 copies) both have to be on
|
|
1899
|
+
the page. Disclosure is for OTHER SUBJECTS, not for a record's
|
|
1900
|
+
own fields. */}
|
|
1901
|
+
<DetailRow label="Original copies" description="Losing an original means an indemnity letter; electronic release = 0">
|
|
1902
|
+
<InlineNumberInput value={copies} onSave={persist(setCopies)} min={0} placeholder="e.g. 3" accessibilityLabel="Original copies" />
|
|
1903
|
+
</DetailRow>
|
|
1904
|
+
<DetailRow label="Delivery terms" description="Where cost and risk pass from seller to buyer">
|
|
1905
|
+
<InlineSelect value={incoterm} onSave={persist(setIncoterm)} options={DELIVERY_TERMS} placeholder="Choose…" accessibilityLabel="Delivery terms" />
|
|
1906
|
+
</DetailRow>
|
|
1907
|
+
{/* SELECT PILLS — the third treatment for a choice, and the
|
|
1908
|
+
right one at TWO SHORT options: every choice stays visible
|
|
1909
|
+
and switching is ONE press, where a select costs two (open,
|
|
1910
|
+
pick) and hides the alternative until you do. It is still
|
|
1911
|
+
one row, because the pills wrap inside the control band.
|
|
1912
|
+
The ladder, by option count and label length:
|
|
1913
|
+
2–3 short, switched often → `ChipGroup` (here)
|
|
1914
|
+
more, or long labels → `InlineSelect` (Document type)
|
|
1915
|
+
the choice IS the task → `RadioPicker`, in a form or
|
|
1916
|
+
wizard — never a row in a grid */}
|
|
1917
|
+
<DetailRow label="Charge terms">
|
|
1918
|
+
<ChipGroup
|
|
1919
|
+
accessibilityLabel="Charge terms"
|
|
1920
|
+
value={chargeTerms}
|
|
1921
|
+
onValueChange={setChargeTerms}
|
|
1922
|
+
options={CHARGE_TERMS.map((o) => ({ value: o.value, label: o.label }))}
|
|
1923
|
+
/>
|
|
1924
|
+
</DetailRow>
|
|
1925
|
+
</DetailTable>
|
|
1926
|
+
</Subsection>
|
|
1927
|
+
{/* The carrier sits WITH its booking — the booking number, the
|
|
1928
|
+
vehicle, the trip and the route below are all its data.
|
|
1929
|
+
|
|
1930
|
+
UNIFORM ROWS, and that is a conclusion, not a default. Two
|
|
1931
|
+
compactions were tried here and both lost to this:
|
|
1932
|
+
|
|
1933
|
+
A read view behind an Edit button dropped its EMPTY fields, which
|
|
1934
|
+
is information loss — an empty row is the difference between "this
|
|
1935
|
+
movement has no vehicle" and "this section has no vehicle field" —
|
|
1936
|
+
and it bought that with a MODE, against the kit's law that the
|
|
1937
|
+
surface IS the editor.
|
|
1938
|
+
|
|
1939
|
+
Grouping correlated fields onto shared rows (route as one row,
|
|
1940
|
+
vehicle + trip as another) cut nine rows to six and read WORSE. A
|
|
1941
|
+
field grid is scanned down its VALUE COLUMN, not by counting rows:
|
|
1942
|
+
three editors of differing widths on one line break the single x
|
|
1943
|
+
every other row shares, so fewer rows carrying more objects each
|
|
1944
|
+
reads busier than more rows that all look alike. Placeholders
|
|
1945
|
+
standing in for the vanished labels finished it — once filled, the
|
|
1946
|
+
row was three boxes with no names.
|
|
1947
|
+
|
|
1948
|
+
Density on a field grid comes from ROW HEIGHT, COLUMN COUNT, and
|
|
1949
|
+
what is lifted into a summary ABOVE it — never from making one row
|
|
1950
|
+
unlike its neighbours. */}
|
|
1951
|
+
<Subsection>
|
|
1952
|
+
<SubsectionHeading><SubsectionHeadingTitle>Carrier & route</SubsectionHeadingTitle></SubsectionHeading>
|
|
1953
|
+
{scheduleNote !== "on-time" ? (
|
|
1954
|
+
<Callout tone="warning">
|
|
1955
|
+
<CalloutText>The schedule changed ({scheduleNote}). Re-check every cut-off and any document already issued against this movement.</CalloutText>
|
|
1956
|
+
</Callout>
|
|
1957
|
+
) : null}
|
|
1958
|
+
<DetailTable labelWidth={150} minHeight={40}>
|
|
1959
|
+
<PartyRow
|
|
1960
|
+
role="Carrier" rec={carrierRec} options={customerOptions}
|
|
1961
|
+
placeholder="Find or create a carrier"
|
|
1962
|
+
onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onRemove={() => setCarrier(null)}
|
|
1963
|
+
/>
|
|
1964
|
+
<DetailRow label="Booking no.">
|
|
1965
|
+
<InlineTextInput value={booking} onSave={persist(setBooking)} placeholder="Add booking number…" accessibilityLabel="Booking number" />
|
|
1966
|
+
</DetailRow>
|
|
1967
|
+
<DetailRow label="Vehicle">
|
|
1968
|
+
<InlineTextInput value={vehicle} onSave={persist(setVehicle)} placeholder="Add vehicle…" accessibilityLabel="Vehicle" />
|
|
1969
|
+
</DetailRow>
|
|
1970
|
+
<DetailRow label="Trip no.">
|
|
1971
|
+
<InlineTextInput value={trip} onSave={persist(setTrip)} placeholder="Add trip number…" accessibilityLabel="Trip number" />
|
|
1972
|
+
</DetailRow>
|
|
1973
|
+
{/* A ROUTE IS NOT FIELDS — see `RouteStops` above. It lives in
|
|
1974
|
+
a `DetailRow` so it keeps the section's label column and the
|
|
1975
|
+
reader's eye stays on one x; what sits in the VALUE is a list,
|
|
1976
|
+
because that is what a route is. */}
|
|
1977
|
+
<DetailRow label="Route">
|
|
1978
|
+
<RouteStops stops={stops} onChange={setStops} />
|
|
1979
|
+
</DetailRow>
|
|
1980
|
+
<DetailRow label="Delivery place" description="The final door, when it differs from the arrival point">
|
|
1981
|
+
<InlineTextInput value={dropPoint} onSave={persist(setDropPoint)} placeholder="Add delivery place…" accessibilityLabel="Delivery place" />
|
|
1982
|
+
</DetailRow>
|
|
1983
|
+
<DetailRow label="Schedule">
|
|
1984
|
+
<InlineSelect value={scheduleNote} onSave={persist(setScheduleNote)} options={SCHEDULE_STATES} accessibilityLabel="Schedule" />
|
|
1985
|
+
</DetailRow>
|
|
1986
|
+
</DetailTable>
|
|
1987
|
+
</Subsection>
|
|
1988
|
+
</SubsectionStack>
|
|
1684
1989
|
</Section>
|
|
1685
1990
|
</View>
|
|
1686
1991
|
|
|
1992
|
+
|
|
1687
1993
|
{/* PROGRESS — the desk handoff, as the ordered positions it is. Sales →
|
|
1688
1994
|
Operations → Accounting: one desk owns the record at a time, the live
|
|
1689
1995
|
one carries the act that leaves it, and a passed desk keeps its own
|
|
@@ -1719,7 +2025,7 @@ export function TplRecord() {
|
|
|
1719
2025
|
onSave={(v) => setDeskSince((p) => ({ ...p, [d.key]: v ?? "" }))}
|
|
1720
2026
|
onClear={() => setDeskSince((p) => ({ ...p, [d.key]: "" }))}
|
|
1721
2027
|
placeholder="Not recorded"
|
|
1722
|
-
accessibilityLabel={`Taken on
|
|
2028
|
+
accessibilityLabel={`Taken on for ${d.label}`}
|
|
1723
2029
|
/>
|
|
1724
2030
|
</PipelineField>
|
|
1725
2031
|
<PipelineField label="Owner" maxWidth={260}>
|
|
@@ -1728,7 +2034,7 @@ export function TplRecord() {
|
|
|
1728
2034
|
value={owner}
|
|
1729
2035
|
onSave={(v) => setDeskOwner((p) => ({ ...p, [d.key]: v }))}
|
|
1730
2036
|
placeholder="Unassigned"
|
|
1731
|
-
accessibilityLabel={`Owner
|
|
2037
|
+
accessibilityLabel={`Owner for ${d.label}`}
|
|
1732
2038
|
/>
|
|
1733
2039
|
</PipelineField>
|
|
1734
2040
|
</>
|
|
@@ -1753,8 +2059,8 @@ export function TplRecord() {
|
|
|
1753
2059
|
</Section>
|
|
1754
2060
|
</View>
|
|
1755
2061
|
|
|
1756
|
-
{/* DOCUMENTS — the document desk: the register Table (search
|
|
1757
|
-
documents
|
|
2062
|
+
{/* DOCUMENTS — the document desk: the register Table (search, Create
|
|
2063
|
+
documents, Add files) whose selection feeds the Use-AI fork below.
|
|
1758
2064
|
No FileDropTarget of its OWN — the WHOLE-RECORD target above already
|
|
1759
2065
|
captures a drop/paste here (and everywhere else on the surface); this
|
|
1760
2066
|
section keeps only its explicit Add-files CTA and the affordance line. */}
|
|
@@ -1765,30 +2071,6 @@ export function TplRecord() {
|
|
|
1765
2071
|
stay discoverable even though the drop target is the whole record. */}
|
|
1766
2072
|
<SectionHeadingTitle description="Drag, paste, or click to add files">Documents</SectionHeadingTitle>
|
|
1767
2073
|
</SectionHeading>
|
|
1768
|
-
{/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
|
|
1769
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
1770
|
-
<View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
|
|
1771
|
-
<SearchInput
|
|
1772
|
-
placeholder="Search documents…"
|
|
1773
|
-
value={docSearch}
|
|
1774
|
-
onChangeText={setDocSearch}
|
|
1775
|
-
accessibilityLabel="Search documents"
|
|
1776
|
-
/>
|
|
1777
|
-
</View>
|
|
1778
|
-
<View style={{ flex: 1 }} />
|
|
1779
|
-
{/* no Create here — GENERATION lives in the Document set section
|
|
1780
|
-
below (this desk = intake) */}
|
|
1781
|
-
{/* PENDING until the user chooses: saving is a decision the dialog
|
|
1782
|
-
asks for (save only / run a task), never a side effect of
|
|
1783
|
-
picking — closing the dialog discards. */}
|
|
1784
|
-
<Button
|
|
1785
|
-
title="Add files"
|
|
1786
|
-
color="secondary"
|
|
1787
|
-
onPress={() => {
|
|
1788
|
-
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
1789
|
-
}}
|
|
1790
|
-
/>
|
|
1791
|
-
</View>
|
|
1792
2074
|
<Table
|
|
1793
2075
|
columns={DOC_COLUMNS}
|
|
1794
2076
|
leading={24}
|
|
@@ -1830,85 +2112,24 @@ export function TplRecord() {
|
|
|
1830
2112
|
</TableRow>
|
|
1831
2113
|
))}
|
|
1832
2114
|
</Table>
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
</
|
|
1846
|
-
{customer ? (
|
|
1847
|
-
<View style={{ gap: 10 }}>
|
|
1848
|
-
{/* The linked-record REFERENCE: the whole box is the keyboard door to the
|
|
1849
|
-
customer's detail (the a11y contract lives in `LinkedRecordBox`); the verbs
|
|
1850
|
-
ride inside — destructive Remove LEFT, go-to Open record RIGHT. */}
|
|
1851
|
-
<LinkedRecordBox
|
|
1852
|
-
icon="building-2"
|
|
1853
|
-
name={customer.name}
|
|
1854
|
-
subtitle={customer.code}
|
|
1855
|
-
facts={[
|
|
1856
|
-
{ label: "Tax ID", value: customer.taxId || "—" },
|
|
1857
|
-
{ label: "Contact", value: customer.contact || "—" },
|
|
1858
|
-
{ label: "City", value: customer.city || "—" },
|
|
1859
|
-
]}
|
|
1860
|
-
doorLabel={`${customer.name} — details`}
|
|
1861
|
-
onOpen={() => setCustomerOpen(true)}
|
|
1862
|
-
actions={
|
|
1863
|
-
<>
|
|
1864
|
-
<Button title="Remove" color="danger" onPress={() => setCustomerId(null)} />
|
|
1865
|
-
<View style={{ flex: 1 }} />
|
|
1866
|
-
<Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the customer's page */ }} />
|
|
1867
|
-
</>
|
|
1868
|
-
}
|
|
1869
|
-
/>
|
|
1870
|
-
{/* invalid STATE is a Callout CO-LOCATED with what it describes —
|
|
1871
|
-
it lives on the customer card, not floating at a section top */}
|
|
1872
|
-
{!taxIdValid ? (
|
|
1873
|
-
<Callout tone="warning">
|
|
1874
|
-
<CalloutText>No valid tax ID — invoicing stays blocked. Replace this customer with one that carries it.</CalloutText>
|
|
1875
|
-
</Callout>
|
|
1876
|
-
) : null}
|
|
1877
|
-
</View>
|
|
1878
|
-
) : (
|
|
1879
|
-
<View style={{ gap: 10 }}>
|
|
1880
|
-
<Combobox
|
|
1881
|
-
options={customerOptions}
|
|
1882
|
-
onValueChange={onPickCustomer}
|
|
1883
|
-
renderOptionContent={(o) => (
|
|
1884
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
1885
|
-
<View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
|
|
1886
|
-
<Icon name="building-2" size={14} color={colors.zinc[600]} />
|
|
1887
|
-
</View>
|
|
1888
|
-
<View style={{ flex: 1, minWidth: 0 }}>
|
|
1889
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{o.label}</Text>
|
|
1890
|
-
{o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(" · ")}</Text> : null}
|
|
1891
|
-
</View>
|
|
1892
|
-
</View>
|
|
1893
|
-
)}
|
|
1894
|
-
reflectSelection={false}
|
|
1895
|
-
allowCustom
|
|
1896
|
-
customOptionPlacement="top"
|
|
1897
|
-
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
1898
|
-
>
|
|
1899
|
-
<ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
|
|
1900
|
-
<ComboboxContent emptyText="No customer matches — type a name to create one" />
|
|
1901
|
-
</Combobox>
|
|
1902
|
-
<Text size="xs" color="muted">
|
|
1903
|
-
No match? Pick “Create new customer …” to add one without leaving the order.
|
|
1904
|
-
</Text>
|
|
1905
|
-
</View>
|
|
1906
|
-
)}
|
|
2115
|
+
{/* the Add, under the register it extends — see the Fees section for
|
|
2116
|
+
why it is not in the heading. PENDING until the user chooses:
|
|
2117
|
+
saving is a decision the dialog asks for (save only / run a task),
|
|
2118
|
+
never a side effect of picking — closing the dialog discards. */}
|
|
2119
|
+
<View style={{ flexDirection: "row" }}>
|
|
2120
|
+
<Button
|
|
2121
|
+
title="Add files"
|
|
2122
|
+
color="primary"
|
|
2123
|
+
onPress={() => {
|
|
2124
|
+
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
2125
|
+
}}
|
|
2126
|
+
/>
|
|
2127
|
+
</View>
|
|
1907
2128
|
</Section>
|
|
1908
2129
|
</View>
|
|
1909
2130
|
|
|
1910
2131
|
{/* FEES — the DETAILED money ledger, both directions (charge = billed
|
|
1911
|
-
to the customer
|
|
2132
|
+
to the customer, cost = paid to a vendor), distinct from Billing's
|
|
1912
2133
|
invoice DOCUMENTS. The drill-down law: a compact register row, and
|
|
1913
2134
|
EVERY row opens its FULL detail in a right-docked entity drawer
|
|
1914
2135
|
(◀ ▶ steps the rows). Add is create-then-refine — a blank fee
|
|
@@ -1917,7 +2138,6 @@ export function TplRecord() {
|
|
|
1917
2138
|
<Section>
|
|
1918
2139
|
<SectionHeading>
|
|
1919
2140
|
<SectionHeadingTitle description="Every fee the order incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
|
|
1920
|
-
<Button title="Add fee" color="secondary" onPress={addFee} />
|
|
1921
2141
|
</SectionHeading>
|
|
1922
2142
|
<SummaryLine
|
|
1923
2143
|
items={[
|
|
@@ -1944,6 +2164,21 @@ export function TplRecord() {
|
|
|
1944
2164
|
})}
|
|
1945
2165
|
</Table>
|
|
1946
2166
|
)}
|
|
2167
|
+
{/* THE ADD SITS WHERE ITS RESULT APPEARS — under the last row, on the
|
|
2168
|
+
list's own left edge. In the heading it sat ABOVE the thing it
|
|
2169
|
+
extends and at a different altitude from it, and it competed with
|
|
2170
|
+
the title for the one line that names the section. Secondary and
|
|
2171
|
+
left, and PRIMARY — in the empty state too, which is what makes it
|
|
2172
|
+
consistent. A section's add is the act that section offers, so it
|
|
2173
|
+
carries the section's weight; a lone `secondary` button reads as
|
|
2174
|
+
though the real action were somewhere else. One verb keeps ONE
|
|
2175
|
+
weight either way: the fault to avoid is an add that is primary on
|
|
2176
|
+
an empty list and secondary on a full one. */}
|
|
2177
|
+
{fees.length > 0 ? (
|
|
2178
|
+
<View style={{ flexDirection: "row" }}>
|
|
2179
|
+
<Button title="Add fee" color="primary" onPress={addFee} />
|
|
2180
|
+
</View>
|
|
2181
|
+
) : null}
|
|
1947
2182
|
</Section>
|
|
1948
2183
|
</View>
|
|
1949
2184
|
|
|
@@ -1972,68 +2207,118 @@ export function TplRecord() {
|
|
|
1972
2207
|
</Callout>
|
|
1973
2208
|
) : null}
|
|
1974
2209
|
|
|
1975
|
-
{/*
|
|
1976
|
-
|
|
2210
|
+
{/* EVERYTHING VISIBLE, and the chrome is what shrinks.
|
|
2211
|
+
Three attempts landed here. Inline BANDS were "spread" — but the
|
|
2212
|
+
spread was never the data: four charge lines across three invoices
|
|
2213
|
+
is nothing. Each band carried a `SubsectionHeading`, its own
|
|
2214
|
+
callout and its own action row, so the CHROME was three times the
|
|
2215
|
+
content. A register then hid those four lines behind three
|
|
2216
|
+
expansions, which trades a real fault for a worse one: you can no
|
|
2217
|
+
longer see what you are paying for.
|
|
2218
|
+
So the lines all stay, and each invoice costs ONE line of chrome —
|
|
2219
|
+
its name, its total, its act. No heading rung, no band callout, no
|
|
2220
|
+
separate action row. A problem rides the CHARGE ROW that has it
|
|
2221
|
+
(`warning` on the line missing its method), which is both smaller
|
|
2222
|
+
than a callout and more precise than one: it names the offender
|
|
2223
|
+
instead of counting offenders. */}
|
|
1977
2224
|
<SubsectionStack>
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
2225
|
+
{invoices.map((inv) => {
|
|
2226
|
+
const missing = missingMethods(inv);
|
|
2227
|
+
const total = invoiceTotal(inv);
|
|
2228
|
+
const ready = total > 0 && missing.length === 0 && customer !== null && taxIdValid;
|
|
2229
|
+
return (
|
|
2230
|
+
<Subsection key={inv.key}>
|
|
2231
|
+
{/* the invoice's ONE line of chrome: what it is, what it comes
|
|
2232
|
+
to, and its reference once issued. No indent under it — the
|
|
2233
|
+
lead line's weight and its total already mark the group, and
|
|
2234
|
+
the `SubsectionStack` beat separates one from the next, so an
|
|
2235
|
+
indent would only push every charge off the page's left edge
|
|
2236
|
+
for nothing. */}
|
|
2237
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
2238
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>{inv.title}</Text>
|
|
2239
|
+
<Text size="sm" tabular color={total > 0 ? "default" : "muted"}>{total > 0 ? money(total) : "—"}</Text>
|
|
2240
|
+
{inv.ref ? (
|
|
2241
|
+
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
2242
|
+
) : null}
|
|
2243
|
+
</View>
|
|
2244
|
+
<DetailTable labelWidth={150} minValueWidth={320}>
|
|
2245
|
+
{inv.charges.map((c) => (
|
|
2246
|
+
<DetailRow
|
|
2247
|
+
key={c.key}
|
|
2248
|
+
label={c.label}
|
|
2249
|
+
warning={c.amount > 0 && !c.method ? "Choose how this was paid" : undefined}
|
|
2250
|
+
>
|
|
2251
|
+
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 4 }}>
|
|
2252
|
+
<View style={{ flexGrow: 1, flexBasis: 150 }}>
|
|
2253
|
+
<InlineNumberInput
|
|
2254
|
+
value={c.amount || null}
|
|
2255
|
+
onSave={(v) => saveCharge(inv.key, c.key, { amount: v ?? 0 })}
|
|
2256
|
+
min={0}
|
|
2257
|
+
format={money}
|
|
2258
|
+
placeholder="—"
|
|
2259
|
+
accessibilityLabel={`Amount for ${c.label}`}
|
|
2260
|
+
/* the list price, one tap, ON the field it fills */
|
|
2261
|
+
actions={
|
|
2262
|
+
c.amount <= 0 && c.standard > 0
|
|
2263
|
+
? <InlineButton title={money(c.standard)} accessibilityLabel={`Use the standard ${c.label} price`} onPress={() => saveCharge(inv.key, c.key, { amount: c.standard })} />
|
|
2264
|
+
: undefined
|
|
2265
|
+
}
|
|
2266
|
+
/>
|
|
2267
|
+
</View>
|
|
2268
|
+
<View style={{ flexGrow: 1, flexBasis: 140, maxWidth: 160 }}>
|
|
2269
|
+
<InlineSelect
|
|
2270
|
+
value={c.method === "" ? null : c.method}
|
|
2271
|
+
onSave={(m) => saveCharge(inv.key, c.key, { method: m })}
|
|
2272
|
+
options={METHODS}
|
|
2273
|
+
placeholder="How paid…"
|
|
2274
|
+
disabled={c.amount <= 0}
|
|
2275
|
+
accessibilityLabel={`Payment method for ${c.label}`}
|
|
2276
|
+
/>
|
|
2277
|
+
</View>
|
|
2278
|
+
</View>
|
|
2279
|
+
</DetailRow>
|
|
2280
|
+
))}
|
|
2281
|
+
{/* AFTER the lines it bills — a committing act goes where its
|
|
2282
|
+
effect lands, which is the end of what it commits, never
|
|
2283
|
+
above it. On the control column like every other CTA on
|
|
2284
|
+
the page (the form-action alignment law). */}
|
|
2285
|
+
<DetailRow label="">
|
|
2286
|
+
<View style={{ flexDirection: "row" }}>
|
|
2287
|
+
<Button
|
|
2288
|
+
title={inv.ref ? "Re-issue" : "Issue invoice"}
|
|
2289
|
+
color={inv.ref ? "secondary" : "primary"}
|
|
2290
|
+
disabled={!ready}
|
|
2291
|
+
onPress={() => setConfirmIssueKey(inv.key)}
|
|
2292
|
+
/>
|
|
2293
|
+
</View>
|
|
2294
|
+
</DetailRow>
|
|
2295
|
+
</DetailTable>
|
|
2296
|
+
</Subsection>
|
|
2297
|
+
);
|
|
2298
|
+
})}
|
|
2299
|
+
</SubsectionStack>
|
|
1991
2300
|
|
|
1992
|
-
{/*
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
2006
|
-
<Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
|
|
2007
|
-
<Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
|
|
2008
|
-
</View>
|
|
2009
|
-
</Subsection>
|
|
2010
|
-
|
|
2011
|
-
{/* deposit band — a named Subsection, never part of the total */}
|
|
2012
|
-
<Subsection>
|
|
2013
|
-
<SubsectionHeading>
|
|
2014
|
-
<SubsectionHeadingTitle>Deposit</SubsectionHeadingTitle>
|
|
2015
|
-
</SubsectionHeading>
|
|
2016
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
|
|
2017
|
-
<Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
|
|
2018
|
-
<View style={{ flexGrow: 1, flexBasis: 160 }}>
|
|
2019
|
-
<InlineNumberInput
|
|
2020
|
-
value={deposit || null}
|
|
2021
|
-
onSave={persist((v: number | null) => setDeposit(v ?? 0))}
|
|
2022
|
-
min={0}
|
|
2023
|
-
format={money}
|
|
2024
|
-
placeholder="—"
|
|
2025
|
-
accessibilityLabel="Deposit amount"
|
|
2026
|
-
/>
|
|
2027
|
-
</View>
|
|
2028
|
-
<Button
|
|
2029
|
-
title="Deposit receipt"
|
|
2030
|
-
color="secondary"
|
|
2031
|
-
disabled={deposit <= 0}
|
|
2032
|
-
onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
|
|
2301
|
+
{/* ONE action row for the section, on the control column. The deposit
|
|
2302
|
+
is a field of the record, not a band of its own — its receipt is a
|
|
2303
|
+
second act on the same row. */}
|
|
2304
|
+
<DetailTable labelWidth={150} minValueWidth={320}>
|
|
2305
|
+
<DetailRow label="Deposit" description="Refundable — never part of the total to collect">
|
|
2306
|
+
<InlineNumberInput
|
|
2307
|
+
value={deposit || null}
|
|
2308
|
+
onSave={persist((v: number | null) => setDeposit(v ?? 0))}
|
|
2309
|
+
min={0}
|
|
2310
|
+
format={money}
|
|
2311
|
+
placeholder="—"
|
|
2312
|
+
accessibilityLabel="Deposit amount"
|
|
2313
|
+
actions={<InlineButton title="Receipt" accessibilityLabel="Deposit receipt" disabled={deposit <= 0} onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])} />}
|
|
2033
2314
|
/>
|
|
2034
|
-
</
|
|
2035
|
-
|
|
2036
|
-
|
|
2315
|
+
</DetailRow>
|
|
2316
|
+
<DetailRow label="">
|
|
2317
|
+
<View style={{ flexDirection: "row" }}>
|
|
2318
|
+
<Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
|
|
2319
|
+
</View>
|
|
2320
|
+
</DetailRow>
|
|
2321
|
+
</DetailTable>
|
|
2037
2322
|
</Section>
|
|
2038
2323
|
</View>
|
|
2039
2324
|
|
|
@@ -2144,7 +2429,7 @@ export function TplRecord() {
|
|
|
2144
2429
|
})}
|
|
2145
2430
|
{/* the output config — values printed on every generated form */}
|
|
2146
2431
|
<Subsection>
|
|
2147
|
-
<DetailTable labelWidth={150}
|
|
2432
|
+
<DetailTable labelWidth={150}>
|
|
2148
2433
|
<DetailRow label="Issuing office" description="Printed in every form's header">
|
|
2149
2434
|
<InlineTextInput value={issuingOffice} onSave={persist(setIssuingOffice)} placeholder="Add the office…" accessibilityLabel="Issuing office" />
|
|
2150
2435
|
</DetailRow>
|
|
@@ -2183,7 +2468,7 @@ export function TplRecord() {
|
|
|
2183
2468
|
<FileRow
|
|
2184
2469
|
key={d.id}
|
|
2185
2470
|
name={d.name}
|
|
2186
|
-
meta={`${d.kind}
|
|
2471
|
+
meta={`${d.kind}, ${fmtSize(d.sizeKB)}`}
|
|
2187
2472
|
mimeType={d.mimeType}
|
|
2188
2473
|
onPress={() => openPreview(docSet, di)}
|
|
2189
2474
|
trailing={
|
|
@@ -2225,7 +2510,7 @@ export function TplRecord() {
|
|
|
2225
2510
|
<SectionHeading>
|
|
2226
2511
|
<SectionHeadingTitle description="Issued on handover — record the delivery facts, generate the receipt.">Delivery receipt</SectionHeadingTitle>
|
|
2227
2512
|
</SectionHeading>
|
|
2228
|
-
<DetailTable labelWidth={150}
|
|
2513
|
+
<DetailTable labelWidth={150}>
|
|
2229
2514
|
<DetailRow label="Received">
|
|
2230
2515
|
<InlineDatePicker value={rcptDate} onSave={persist(setRcptDate)} locale="en-US" accessibilityLabel="Received date" />
|
|
2231
2516
|
</DetailRow>
|
|
@@ -2259,7 +2544,7 @@ export function TplRecord() {
|
|
|
2259
2544
|
{rcptDoc ? (
|
|
2260
2545
|
<FileRow
|
|
2261
2546
|
name={rcptDoc.name}
|
|
2262
|
-
meta={`${rcptDoc.kind}
|
|
2547
|
+
meta={`${rcptDoc.kind}, ${fmtSize(rcptDoc.sizeKB)}`}
|
|
2263
2548
|
mimeType={rcptDoc.mimeType}
|
|
2264
2549
|
onPress={() => openPreview([rcptDoc], 0)}
|
|
2265
2550
|
trailing={
|
|
@@ -2279,70 +2564,6 @@ export function TplRecord() {
|
|
|
2279
2564
|
</Section>
|
|
2280
2565
|
</View>
|
|
2281
2566
|
|
|
2282
|
-
{/* ACTIVITY — the audit trail: every change on the record, who and
|
|
2283
|
-
when. Seeded history + LIVE appends from this surface's real
|
|
2284
|
-
actions (issue, fees, handoff). */}
|
|
2285
|
-
<View onLayout={nav.register("activity")}>
|
|
2286
|
-
<Section>
|
|
2287
|
-
<SectionHeading>
|
|
2288
|
-
<SectionHeadingTitle description="Every change on the record — who made it, and when.">Activity</SectionHeadingTitle>
|
|
2289
|
-
</SectionHeading>
|
|
2290
|
-
<Timeline items={activity} />
|
|
2291
|
-
</Section>
|
|
2292
|
-
</View>
|
|
2293
|
-
|
|
2294
|
-
{/* HANDOFF — the reference mark of the boundary: handing off CREATES
|
|
2295
|
-
the next desk's record on ITS table and links it here. The trail
|
|
2296
|
-
marks whom · where · when + the linked sibling; the sibling owns
|
|
2297
|
-
its own summary. */}
|
|
2298
|
-
<View onLayout={nav.register("handoff")}>
|
|
2299
|
-
<Section>
|
|
2300
|
-
<SectionHeading>
|
|
2301
|
-
<SectionHeadingTitle description="Handing off creates the next desk's record and links it here for reference.">Handoff</SectionHeadingTitle>
|
|
2302
|
-
</SectionHeading>
|
|
2303
|
-
{/* the records the handoffs CREATED — the SAME linked-record
|
|
2304
|
-
composition as the Customer section (a bordered view + visible
|
|
2305
|
-
actions; copy and customize) */}
|
|
2306
|
-
{siblings.length > 0 ? (
|
|
2307
|
-
<View style={{ gap: 16 }}>
|
|
2308
|
-
{siblings.map((sb) => (
|
|
2309
|
-
<LinkedRecordBox
|
|
2310
|
-
key={sb.id}
|
|
2311
|
-
icon="file-text"
|
|
2312
|
-
name={sb.code}
|
|
2313
|
-
subtitle={`${sb.desk} record`}
|
|
2314
|
-
facts={[
|
|
2315
|
-
{ label: "Assignee", value: sb.assignee || "—" },
|
|
2316
|
-
{ label: "Created", value: sb.at },
|
|
2317
|
-
{ label: "Note", value: sb.note || "—" },
|
|
2318
|
-
]}
|
|
2319
|
-
doorLabel={`${sb.code} — details`}
|
|
2320
|
-
onOpen={() => setSiblingOpen(sb.id)}
|
|
2321
|
-
actions={
|
|
2322
|
-
<>
|
|
2323
|
-
<View style={{ flex: 1 }} />
|
|
2324
|
-
<Button title="Open record" color="secondary" onPress={() => { /* a real app navigates to the sibling's page */ }} />
|
|
2325
|
-
</>
|
|
2326
|
-
}
|
|
2327
|
-
/>
|
|
2328
|
-
))}
|
|
2329
|
-
</View>
|
|
2330
|
-
) : null}
|
|
2331
|
-
{handoffs.length > 0 ? <Timeline items={handoffs} /> : null}
|
|
2332
|
-
{/* Nothing handed off YET is a real state, not an absent section — and
|
|
2333
|
-
its empty state carries NO action: the act lives on the live stage
|
|
2334
|
-
in Progress, so a CTA here would be the second copy this section
|
|
2335
|
-
exists to avoid. */}
|
|
2336
|
-
{siblings.length === 0 && handoffs.length === 0 ? (
|
|
2337
|
-
<EmptyState icon="send" message="Not handed off yet" hint={`${stageOf(stage).label} still holds this record — hand it off from Progress above.`} />
|
|
2338
|
-
) : null}
|
|
2339
|
-
{/* No CTA here. The act that leaves a desk belongs ON that desk — it
|
|
2340
|
-
rides the live stage in the Progress pipeline. This section is the
|
|
2341
|
-
RESULT: what the handoff created and when. A second button here
|
|
2342
|
-
would offer the same act twice, in the place with less context. */}
|
|
2343
|
-
</Section>
|
|
2344
|
-
</View>
|
|
2345
|
-
|
|
2346
2567
|
{/* the rail lists EVERY section — the danger zone included, with the
|
|
2347
2568
|
SAME section anatomy (heading + description) as everything else;
|
|
2348
2569
|
the fenced red card is the section's BODY, not its heading */}
|
|
@@ -2396,7 +2617,7 @@ export function TplRecord() {
|
|
|
2396
2617
|
{/* the same inline-chip table vocabulary as the record's field grids —
|
|
2397
2618
|
only the commit differs (Create & attach) */}
|
|
2398
2619
|
<View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 8 }}>
|
|
2399
|
-
<DetailTable labelWidth={150}
|
|
2620
|
+
<DetailTable labelWidth={150}>
|
|
2400
2621
|
<DetailRow label="Customer name">
|
|
2401
2622
|
<InlineTextInput
|
|
2402
2623
|
value={custDraft?.name ?? ""}
|
|
@@ -2410,19 +2631,15 @@ export function TplRecord() {
|
|
|
2410
2631
|
</DetailRow>
|
|
2411
2632
|
{/* the field-level failure rides the ROW (`DetailRow error` —
|
|
2412
2633
|
FormField's alert semantics), not a loose line below the table */}
|
|
2413
|
-
<DetailRow
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
onPress={fetchIntoDraft}
|
|
2423
|
-
/>
|
|
2424
|
-
}
|
|
2425
|
-
>
|
|
2634
|
+
<DetailRow label="Tax ID" error={taxIdError}>
|
|
2635
|
+
{/* Fetch is NOT a verb about this value — it reads the tax ID and
|
|
2636
|
+
fills the contact and city BELOW it, so it belongs to the form,
|
|
2637
|
+
not to the field (which is why it isn't an `InlineButton` in
|
|
2638
|
+
`actions`). It composes into a row inside this value cell, so
|
|
2639
|
+
it costs the one row that wants it instead of a column every
|
|
2640
|
+
row would reserve. */}
|
|
2641
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
|
2642
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
2426
2643
|
<InlineTextInput
|
|
2427
2644
|
value={custDraft?.taxId ?? ""}
|
|
2428
2645
|
onSave={(v) => {
|
|
@@ -2432,6 +2649,15 @@ export function TplRecord() {
|
|
|
2432
2649
|
placeholder="Add tax ID…"
|
|
2433
2650
|
accessibilityLabel="Tax ID"
|
|
2434
2651
|
/>
|
|
2652
|
+
</View>
|
|
2653
|
+
<Button
|
|
2654
|
+
title="Fetch"
|
|
2655
|
+
color="secondary"
|
|
2656
|
+
loading={fetching}
|
|
2657
|
+
disabled={!TAX_ID_RE.test((custDraft?.taxId ?? "").trim())}
|
|
2658
|
+
onPress={fetchIntoDraft}
|
|
2659
|
+
/>
|
|
2660
|
+
</View>
|
|
2435
2661
|
</DetailRow>
|
|
2436
2662
|
<DetailRow label="Contact">
|
|
2437
2663
|
<InlineTextInput
|
|
@@ -2503,7 +2729,7 @@ export function TplRecord() {
|
|
|
2503
2729
|
<DetailRow label="Fee">
|
|
2504
2730
|
<InlineTextInput value={openFee.name} onSave={persist((v: string) => patchFee(openFee.id, { name: v }))} placeholder="Name the fee…" accessibilityLabel="Fee name" />
|
|
2505
2731
|
</DetailRow>
|
|
2506
|
-
<DetailRow label="Type" description="Charge — billed to the customer
|
|
2732
|
+
<DetailRow label="Type" description="Charge — billed to the customer; Cost — paid to a vendor">
|
|
2507
2733
|
<InlineSelect
|
|
2508
2734
|
value={openFee.direction}
|
|
2509
2735
|
options={[{ value: "charge", label: "Charge" }, { value: "cost", label: "Cost" }]}
|
|
@@ -2573,35 +2799,6 @@ export function TplRecord() {
|
|
|
2573
2799
|
</DrawerFooter>
|
|
2574
2800
|
</Drawer>
|
|
2575
2801
|
|
|
2576
|
-
{/* the SIBLING record's drawer — the handoff-created record's reference
|
|
2577
|
-
info + the navigate verb (a real app routes to its page). */}
|
|
2578
|
-
<Drawer open={openSibling !== null} onOpenChange={(o) => { if (!o) setSiblingOpen(null); }} title={openSibling?.code ?? ""} width={440}>
|
|
2579
|
-
{openSibling ? (
|
|
2580
|
-
<View style={{ padding: 20 }}>
|
|
2581
|
-
<DetailTable labelWidth={110}>
|
|
2582
|
-
<DetailRow label="Desk"><InlineStatic value={openSibling.desk} /></DetailRow>
|
|
2583
|
-
<DetailRow label="Assignee"><InlineStatic value={openSibling.assignee || "—"} /></DetailRow>
|
|
2584
|
-
<DetailRow label="Created"><InlineStatic value={openSibling.at} tabular /></DetailRow>
|
|
2585
|
-
<DetailRow label="From"><InlineStatic value={code} tabular /></DetailRow>
|
|
2586
|
-
<DetailRow label="Handoff note"><InlineStatic value={openSibling.note || "—"} /></DetailRow>
|
|
2587
|
-
</DetailTable>
|
|
2588
|
-
</View>
|
|
2589
|
-
) : (
|
|
2590
|
-
<View />
|
|
2591
|
-
)}
|
|
2592
|
-
<DrawerFooter>
|
|
2593
|
-
{/* Recall = the semi-destructive undo — behind the details, confirmed.
|
|
2594
|
-
Offered ONLY on the handoff that put the record where it is. A
|
|
2595
|
-
superseded one cannot be undone in place: withdrawing the middle
|
|
2596
|
-
step would leave the record behind a sibling that still exists, so
|
|
2597
|
-
`stage` and the linked records would disagree. Walk it back one
|
|
2598
|
-
handoff at a time. */}
|
|
2599
|
-
{openSibling && openSibling.deskKey === stage ? <Button title="Recall handoff" color="danger-secondary" onPress={() => recallHandoff(openSibling)} /> : null}
|
|
2600
|
-
{/* a real app navigates to the sibling record's page */}
|
|
2601
|
-
<Button title="Open record" color="secondary" onPress={() => {}} />
|
|
2602
|
-
</DrawerFooter>
|
|
2603
|
-
</Drawer>
|
|
2604
|
-
|
|
2605
2802
|
{/* the handoff dialog — who receives the record + a note; the rows name
|
|
2606
2803
|
exactly WHAT is being handed off. Confirm writes the trail entry. */}
|
|
2607
2804
|
<Dialog width={480} open={handoffOpen} onOpenChange={(o) => { if (!o) { setHandoffOpen(false); setHandoffAssignee(null); setHandoffNote(""); } }}>
|
|
@@ -2621,13 +2818,58 @@ export function TplRecord() {
|
|
|
2621
2818
|
<Button title={`Hand off to ${stageOf(handoffTo).label}`} color="primary" disabled={handoffAssignee == null} onPress={confirmHandoff} />
|
|
2622
2819
|
</DialogFooter>
|
|
2623
2820
|
</Dialog>
|
|
2624
|
-
{/*
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
{wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
|
|
2821
|
+
{/* Reserves the discussion panel's column — the panel itself floats over
|
|
2822
|
+
it, like the rail on the left, so the scroller keeps the full page. */}
|
|
2823
|
+
{wide ? <View style={{ width: PANEL_GUTTER, flexShrink: 0 }} /> : null}
|
|
2628
2824
|
</View>
|
|
2629
2825
|
</ScrollView>
|
|
2630
2826
|
|
|
2827
|
+
{/* Narrow: the same thread, in the panel shape a small screen has — a
|
|
2828
|
+
right-docked Drawer off the bar's own comments control. */}
|
|
2829
|
+
<Drawer open={commentsOpen} onOpenChange={setCommentsOpen} title="Comments" width={380}>
|
|
2830
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 20, gap: 12 }}>
|
|
2831
|
+
<CommentList
|
|
2832
|
+
comments={comments}
|
|
2833
|
+
currentMemberId="mem_01"
|
|
2834
|
+
resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
|
|
2835
|
+
onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
|
|
2836
|
+
onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
|
|
2837
|
+
/* the file-capable edit form — the same injection the product uses */
|
|
2838
|
+
renderEditForm={(p) => <CommentFileEditForm {...p} />}
|
|
2839
|
+
/* attachments render as the SAME thumbnail grid the product uses —
|
|
2840
|
+
press previews in the gallery (the desk's preview state) */
|
|
2841
|
+
renderFiles={(files) => (
|
|
2842
|
+
<FileGrid
|
|
2843
|
+
files={files.map(commentFileToDisplay)}
|
|
2844
|
+
itemSize={84}
|
|
2845
|
+
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
2846
|
+
/>
|
|
2847
|
+
)}
|
|
2848
|
+
/* compact timestamp — the product formats RELATIVE ("2 days ago",
|
|
2849
|
+
date-fns); a template stays dependency-free and deterministic */
|
|
2850
|
+
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" })}`; }}
|
|
2851
|
+
/>
|
|
2852
|
+
|
|
2853
|
+
</ScrollView>
|
|
2854
|
+
<View style={{ padding: 20, paddingTop: 0 }}>
|
|
2855
|
+
{/* THE kit Composer — the one composing surface everywhere (chat,
|
|
2856
|
+
agent prompts, comments): attach via actionsButton, the staged
|
|
2857
|
+
files (removable until Send) ride the files slot. */}
|
|
2858
|
+
<Composer
|
|
2859
|
+
onSend={(content) => {
|
|
2860
|
+
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() }]);
|
|
2861
|
+
setPendingFiles([]);
|
|
2862
|
+
}}
|
|
2863
|
+
placeholder="Write a comment…"
|
|
2864
|
+
sendLabel="Send"
|
|
2865
|
+
actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
|
|
2866
|
+
files={pendingFiles.length > 0 ? (
|
|
2867
|
+
<FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
|
|
2868
|
+
) : undefined}
|
|
2869
|
+
/>
|
|
2870
|
+
</View>
|
|
2871
|
+
</Drawer>
|
|
2872
|
+
|
|
2631
2873
|
{/* ── the document desk's overlays */}
|
|
2632
2874
|
<FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
|
|
2633
2875
|
<Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
|
|
@@ -2641,7 +2883,7 @@ export function TplRecord() {
|
|
|
2641
2883
|
<ChangeReview>
|
|
2642
2884
|
<Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
|
|
2643
2885
|
<DialogHeader>
|
|
2644
|
-
<DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI
|
|
2886
|
+
<DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI (${picked.length} ${picked.length === 1 ? "file" : "files"})`}</DialogHeaderTitle>
|
|
2645
2887
|
</DialogHeader>
|
|
2646
2888
|
{phase === "running" ? (
|
|
2647
2889
|
// The streaming feed gets its OWN scroller: FollowScroll (the chat list's
|
|
@@ -2704,7 +2946,7 @@ export function TplRecord() {
|
|
|
2704
2946
|
<View style={{ gap: 8 }}>
|
|
2705
2947
|
<ChangeReviewHeader />
|
|
2706
2948
|
{/* ONE section for the RECORD — ChangeFields stacks a ChangeField
|
|
2707
|
-
per proposed value: label
|
|
2949
|
+
per proposed value: label, the − band when replacing, the
|
|
2708
2950
|
editable + value. Editing IS the review; each field decides
|
|
2709
2951
|
for ITSELF (Keep/Drop), and Apply commits the kept rows. */}
|
|
2710
2952
|
<Change id="order">
|
|
@@ -2797,8 +3039,8 @@ export function TplRecord() {
|
|
|
2797
3039
|
<View style={{ gap: 14 }}>
|
|
2798
3040
|
<ChangeReviewHeader title="Findings" />
|
|
2799
3041
|
{/* Display-only: findings inform the verdict the footer records.
|
|
2800
|
-
The kit `Finding` owns severity word
|
|
2801
|
-
PROMINENT metric
|
|
3042
|
+
The kit `Finding` owns severity word, title, detail, the
|
|
3043
|
+
PROMINENT metric, Sources; hairlines separate them. */}
|
|
2802
3044
|
{FINDINGS.map((c, i) => (
|
|
2803
3045
|
<Fragment key={c.id}>
|
|
2804
3046
|
{i > 0 ? <Divider /> : null}
|
|
@@ -2892,6 +3134,72 @@ export function TplRecord() {
|
|
|
2892
3134
|
/>
|
|
2893
3135
|
) : null}
|
|
2894
3136
|
|
|
3137
|
+
{/* THE DISCUSSION PANEL — pinned in the right gutter, mirroring the rail.
|
|
3138
|
+
It is not a Section and has no place in the rail, because it is not a
|
|
3139
|
+
PART of the record: it is about all of it. A thread parked at one
|
|
3140
|
+
position in a top-to-bottom read is unreachable from every other, which
|
|
3141
|
+
is exactly when a coworker's note matters — so it stays on screen while
|
|
3142
|
+
you work anywhere on the page.
|
|
3143
|
+
|
|
3144
|
+
It floats over its reserved column rather than sitting in the scroll row,
|
|
3145
|
+
for the same reason the rail does: the ScrollView keeps the full page
|
|
3146
|
+
width, so the wheel scrolls the record from anywhere. Its own overflow is
|
|
3147
|
+
the panel's, so a long thread scrolls inside itself and never lengthens
|
|
3148
|
+
the record. */}
|
|
3149
|
+
{pageWidth == null || !wide ? null : (
|
|
3150
|
+
<View style={{ position: "absolute", top: 28, right: panelRight, bottom: 28, width: PANEL_W, gap: 10 }}>
|
|
3151
|
+
{/* The panel is a PEER of the sections beside it, not a widget, so its
|
|
3152
|
+
title takes the ramp's section rung rather than a hand-picked size —
|
|
3153
|
+
and it takes the WHOLE construct. `SectionHeadingTitle` carries
|
|
3154
|
+
`flex: 1` so it can push meta to a heading row's right edge; dropped
|
|
3155
|
+
straight into this column it grows VERTICALLY instead and starves the
|
|
3156
|
+
thread beneath it. The row wrapper is what makes that flex mean what
|
|
3157
|
+
it was written to mean. */}
|
|
3158
|
+
<SectionHeading>
|
|
3159
|
+
<SectionHeadingTitle>Comments</SectionHeadingTitle>
|
|
3160
|
+
</SectionHeading>
|
|
3161
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12, paddingBottom: 8 }}>
|
|
3162
|
+
<CommentList
|
|
3163
|
+
comments={comments}
|
|
3164
|
+
currentMemberId="mem_01"
|
|
3165
|
+
resolveMember={(mid) => { const m = TEAM.find((x) => x.id === mid); return m ? { id: m.id, name: m.name ?? null } : null; }}
|
|
3166
|
+
onEdit={(cid, content, efiles) => setComments((prev) => prev.map((c) => (c.id === cid ? { ...c, content, files: efiles ?? undefined, updated_at: new Date().toISOString() } : c)))}
|
|
3167
|
+
onDelete={(cid) => setComments((prev) => prev.filter((c) => c.id !== cid))}
|
|
3168
|
+
/* the file-capable edit form — the same injection the product uses */
|
|
3169
|
+
renderEditForm={(p) => <CommentFileEditForm {...p} />}
|
|
3170
|
+
/* attachments render as the SAME thumbnail grid the product uses —
|
|
3171
|
+
press previews in the gallery (the desk's preview state) */
|
|
3172
|
+
renderFiles={(files) => (
|
|
3173
|
+
<FileGrid
|
|
3174
|
+
files={files.map(commentFileToDisplay)}
|
|
3175
|
+
itemSize={84}
|
|
3176
|
+
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
3177
|
+
/>
|
|
3178
|
+
)}
|
|
3179
|
+
/* compact timestamp — the product formats RELATIVE ("2 days ago",
|
|
3180
|
+
date-fns); a template stays dependency-free and deterministic */
|
|
3181
|
+
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" })}`; }}
|
|
3182
|
+
/>
|
|
3183
|
+
|
|
3184
|
+
</ScrollView>
|
|
3185
|
+
{/* THE kit Composer — the one composing surface everywhere (chat,
|
|
3186
|
+
agent prompts, comments): attach via actionsButton, the staged
|
|
3187
|
+
files (removable until Send) ride the files slot. */}
|
|
3188
|
+
<Composer
|
|
3189
|
+
onSend={(content) => {
|
|
3190
|
+
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() }]);
|
|
3191
|
+
setPendingFiles([]);
|
|
3192
|
+
}}
|
|
3193
|
+
placeholder="Write a comment…"
|
|
3194
|
+
sendLabel="Send"
|
|
3195
|
+
actionsButton={<IconButton size="lg" color="secondary" icon="plus" tooltip="Attach files" onPress={attachToComment} />}
|
|
3196
|
+
files={pendingFiles.length > 0 ? (
|
|
3197
|
+
<FileRows files={pendingFiles.map(commentFileToDisplay)} onRemove={(f) => setPendingFiles((prev) => prev.filter((x) => x.id !== f.id))} />
|
|
3198
|
+
) : undefined}
|
|
3199
|
+
/>
|
|
3200
|
+
</View>
|
|
3201
|
+
)}
|
|
3202
|
+
|
|
2895
3203
|
{/* The rail floats over its reserved gutter — pinned while the record
|
|
2896
3204
|
scrolls, and out of the scroll row so the page stays wheel-scrollable
|
|
2897
3205
|
edge to edge. */}
|