@lotics/ui 23.2.0 → 24.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.
@@ -6,11 +6,9 @@ import { colors, solid, type ColorName } from "@lotics/ui/colors";
6
6
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
7
7
  import { Alert } from "@lotics/ui/alert";
8
8
  import { Badge } from "@lotics/ui/badge";
9
- import { InlineButton } from "@lotics/ui/inline_button";
10
9
  import { Button } from "@lotics/ui/button";
11
10
  import { CardFooter } from "@lotics/ui/card";
12
11
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
13
- import { CheckCircle } from "@lotics/ui/check_circle";
14
12
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
15
13
  import { Divider } from "@lotics/ui/divider";
16
14
  import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
@@ -20,15 +18,11 @@ import { FormField } from "@lotics/ui/form_field";
20
18
  import { Icon, type IconName } from "@lotics/ui/icon";
21
19
  import { IconButton } from "@lotics/ui/icon_button";
22
20
  import { ListItem } from "@lotics/ui/list_item";
23
- import { PressableHighlight } from "@lotics/ui/pressable_highlight";
24
- import { CaptureRow } from "@lotics/ui/capture_row";
25
- import { TaskActions, TaskItem, TaskList, TaskStatus, TaskSubRow, TaskTitle } from "@lotics/ui/task";
26
- import { SuggestionChip } from "@lotics/ui/suggestion_chip";
21
+ import { TplRecord } from "./tpl_record";
27
22
  import { Screen, ScreenRouter, useScreenRouter } from "@lotics/ui/screen_router";
28
23
  import { SummaryLine } from "@lotics/ui/summary_line";
29
24
  import { NumberInput } from "@lotics/ui/number_input";
30
25
  import { Pagination } from "@lotics/ui/pagination";
31
- import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
32
26
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
33
27
  import { SearchInput } from "@lotics/ui/search_input";
34
28
  import { Select } from "@lotics/ui/select";
@@ -37,21 +31,13 @@ import { ColumnFilter, type ColumnFilterValue } from "@lotics/ui/column_filter";
37
31
  import { OptionList } from "@lotics/ui/option_list";
38
32
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
39
33
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
40
- import { Picker, type PickerOption } from "@lotics/ui/picker";
34
+ import { type PickerOption } from "@lotics/ui/picker";
41
35
  import { formatMoney } from "@lotics/ui/format_money";
42
- import { toISODate } from "@lotics/ui/format_date";
43
36
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
44
37
  import { InlineNumberInput } from "@lotics/ui/inline_number_input";
45
38
  import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
46
39
  import type { MemberSelectMember } from "@lotics/ui/member_select";
47
- import { OptionBadge, type OptionValue } from "@lotics/ui/option_badge";
48
- import { FileRows } from "@lotics/ui/file_rows";
49
- import { FileRow } from "@lotics/ui/file_row";
50
- import { pickFiles } from "@lotics/ui/file_picker";
51
40
  import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
52
- import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
53
- import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
54
- import { ProgressBar } from "@lotics/ui/progress_bar";
55
41
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
56
42
  import { AgentRunScope, AgentRunPane, AgentRunActions, type AgentRunLike, type AgentRunQuestion } from "@lotics/ui/agent_run_pane";
57
43
  import { ResultHeader } from "@lotics/ui/result_header";
@@ -59,17 +45,10 @@ import { Confidence } from "@lotics/ui/confidence";
59
45
  import { CardSelectItem } from "@lotics/ui/card_select_item";
60
46
  import { FileDropzone } from "@lotics/ui/file_dropzone";
61
47
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
62
- import { MemberSelect } from "@lotics/ui/member_select";
63
- import { Callout, CalloutText } from "@lotics/ui/callout";
64
- import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
65
48
  import { Link } from "@lotics/ui/link";
66
49
  import { TextInputField } from "@lotics/ui/text_input_field";
67
- import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle } from "@lotics/ui/section_heading";
68
- import { DangerZone } from "@lotics/ui/danger_zone";
69
- import { ChipGroup } from "@lotics/ui/chip_group";
70
50
  import type { DisplayFile } from "@lotics/ui/file_thumbnail";
71
51
  import { InlineSelect } from "@lotics/ui/inline_select";
72
- import { InlineStatic } from "@lotics/ui/inline_static";
73
52
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
74
53
  import { useSelection } from "@lotics/ui/use_selection";
75
54
 
@@ -103,30 +82,6 @@ const MEMBERS: MemberSelectMember[] = [
103
82
  ];
104
83
  const memberIdOf = (name: string) => MEMBERS.find((m) => m.name === name)?.id ?? null;
105
84
 
106
- const PRIORITY_OPTS = [
107
- { value: "standard", label: "Standard" },
108
- { value: "urgent", label: "Urgent" },
109
- ];
110
- const PRIORITY_DOT: Record<string, string> = { standard: colors.zinc[400], urgent: colors.amber[500] };
111
-
112
- const RECORD_TAGS: Record<string, OptionValue> = {
113
- corporate: { key: "corporate", label: "Corporate", color: "blue" },
114
- courier: { key: "courier", label: "Courier", color: "amber" },
115
- vip: { key: "vip", label: "VIP", color: "emerald" },
116
- };
117
- const TAG_OPTIONS = Object.values(RECORD_TAGS).map((t) => ({ value: String(t.key), label: t.label }));
118
- const renderTagBadge = (o: { value: string; label?: string }) => (
119
- <OptionBadge value={RECORD_TAGS[o.value] ?? { key: o.value, label: o.label ?? o.value, color: null }} variant="dot" />
120
- );
121
-
122
- // Seeded documents preview for real (SVG data URIs); picked files map to
123
- // local object URLs — a host wires its own upload here.
124
- const sampleDoc = (label: string, fill: string): DisplayFile => ({
125
- id: `seed-${label}`,
126
- filename: `${label}.png`,
127
- mimeType: "image/png",
128
- url: "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="${fill}"/><text x="160" y="128" font-family="sans-serif" font-size="22" fill="white" text-anchor="middle">${label}</text></svg>`),
129
- });
130
85
  let fileSeq = 0;
131
86
  const toDisplayFiles = (files: File[]): DisplayFile[] =>
132
87
  files.map((f) => ({ id: `f_${(fileSeq += 1)}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) }));
@@ -135,175 +90,12 @@ const toDisplayFiles = (files: File[]): DisplayFile[] =>
135
90
  const persist = <T,>(set: (v: T) => void) => (v: T) =>
136
91
  new Promise<void>((resolve) => { setTimeout(() => { set(v); resolve(); }, 300); });
137
92
 
138
- // The HANDOFF checklist — this desk's tasks on the record; the register shows
139
- // done/total per row, and open tasks WARN in the handoff dialog (never block).
140
- const HANDOFF_TASKS = [
141
- "Confirm the service scope",
142
- "Collect the identity documents",
143
- "Verify the customer's tax code",
144
- "Collect the service fee",
145
- "Prepare the handoff summary",
146
- ];
147
- interface RecTask {
148
- id: string;
149
- label: string;
150
- done: boolean;
151
- assignee: string | null;
152
- due: string;
153
- }
154
- // Monotonic id for user-added tasks — a length-derived id would collide after
155
- // a removal (delete t_1, add → a second t_2) and break keys + patch-by-id.
156
- let recTaskSeq = 0;
157
- const newTaskId = () => `t_${(recTaskSeq += 1)}`;
158
- // Seeded from the record's FACTS so the checklist never contradicts the row:
159
- // the docs task tracks the Awaiting-docs status, the fee task tracks daThu,
160
- // the wrap-up tasks clear only on completed records.
161
93
  /** "02/06" (dd/MM in the register) → the ISO date the pickers edit. */
162
94
  const isoReceived = (ddmm: string) => {
163
95
  const [d, m] = ddmm.split("/");
164
96
  return `2026-${m}-${d}`;
165
97
  };
166
98
 
167
- const seedTasks = (hs: HoSo): RecTask[] => {
168
- const done = [
169
- true,
170
- hs.trangThai !== "chobosung",
171
- hs.trangThai === "hoantat",
172
- hs.daThu,
173
- hs.trangThai === "hoantat",
174
- ];
175
- return HANDOFF_TASKS.map((label, i) => ({
176
- id: `${hs.ma}_${i}`,
177
- label,
178
- done: done[i] ?? false,
179
- assignee: i % 2 === 0 ? MEMBERS[i % MEMBERS.length].id : null,
180
- due: i === 1 && !done[1] ? "2026-07-08" : "",
181
- }));
182
- };
183
-
184
- /** The interactive checklist — the task templates' row vocabulary (sibling
185
- * CheckCircle + struck transparent InlineTextInput + the dashed-ring capture
186
- * row). Shared by the register's task peek and the drawer's. */
187
- function TasksChecklist({ tasks, onChange, suggestions, onDismissSuggestion }: {
188
- tasks: RecTask[];
189
- onChange: (next: RecTask[]) => void;
190
- suggestions: string[];
191
- onDismissSuggestion: (label: string) => void;
192
- }) {
193
- const [draft, setDraft] = useState("");
194
- const patch = (tid: string, p: Partial<RecTask>) => onChange(tasks.map((x) => (x.id === tid ? { ...x, ...p } : x)));
195
- const add = () => {
196
- const label = draft.trim();
197
- if (!label) return;
198
- onChange([...tasks, { id: newTaskId(), label, done: false, assignee: null, due: "" }]);
199
- setDraft("");
200
- };
201
- const addSuggested = (label: string) => onChange([...tasks, { id: newTaskId(), label, done: false, assignee: null, due: "" }]);
202
- const pending = suggestions.filter((l) => !tasks.some((t) => t.label === l));
203
- return (
204
- <TaskList>
205
- {/* ONE anatomy for every surface. The row is the title; the assignee and
206
- the due date hang under it as named fields, and the pair splits onto
207
- two lines only when the surface is too narrow to seat both — a drawer,
208
- a peek, a phone. The template no longer picks. */}
209
- {tasks.map((t) => (
210
- <TaskItem key={t.id}>
211
- <TaskStatus>
212
- <CheckCircle state={t.done ? "done" : "none"} onChange={(on) => patch(t.id, { done: on })} accessibilityLabel={t.label} />
213
- </TaskStatus>
214
- <TaskTitle>
215
- <InlineTextInput
216
- variant="bare"
217
- value={t.label}
218
- onSave={(v) => patch(t.id, { label: v })}
219
- struck={t.done}
220
- accessibilityLabel="Task title"
221
- />
222
- </TaskTitle>
223
- <TaskActions>
224
- <ActionMenu
225
- items={[{ key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => onChange(tasks.filter((x) => x.id !== t.id)) }]}
226
- accessibilityLabel={`Task options: ${t.label}`}
227
- />
228
- </TaskActions>
229
- {/* the task's own FIELDS hang UNDER it, name beside value, one indent
230
- step in — never cells in a shared column, which put each name a
231
- quarter of the surface from the control it names */}
232
- <TaskSubRow label="Assignee">
233
- <InlineMemberSelect
234
- members={MEMBERS}
235
- value={t.assignee}
236
- onSave={(m) => patch(t.id, { assignee: m })}
237
- placeholder="Assign…"
238
- accessibilityLabel={`Assignee for ${t.label}`}
239
- />
240
- </TaskSubRow>
241
- <TaskSubRow label="Due">
242
- <InlineDatePicker
243
- value={t.due}
244
- onSave={(v) => patch(t.id, { due: v })}
245
- placeholder="Set due…"
246
- accessibilityLabel={`Due for ${t.label}`}
247
- />
248
- </TaskSubRow>
249
- </TaskItem>
250
- ))}
251
- {/* SUGGESTIONS are pills, not rows — a chip can't be mistaken for a
252
- task. Tap = materialize; ✕ = dismiss it for this record. */}
253
- {pending.length > 0 ? (
254
- <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, paddingTop: 2, paddingBottom: 4 }}>
255
- {pending.map((label) => (
256
- <SuggestionChip key={label} label={label} onAdd={() => addSuggested(label)} onDismiss={() => onDismissSuggestion(label)} />
257
- ))}
258
- </View>
259
- ) : null}
260
- <CaptureRow value={draft} onChangeText={setDraft} onSubmit={add} placeholder="Add a task…" accessibilityLabel="Add a task" />
261
- </TaskList>
262
- );
263
- }
264
-
265
- /** The task PEEK — done/total + a live mini bar as a pressable trigger; the
266
- * popover holds the full interactive checklist, so tasks tick from the
267
- * register without opening the drawer. */
268
- function TasksPeek({ tasks, onChange, suggestions, onDismissSuggestion }: { tasks: RecTask[]; onChange: (next: RecTask[]) => void; suggestions: string[]; onDismissSuggestion: (label: string) => void }) {
269
- const done = tasks.filter((t) => t.done).length;
270
- return (
271
- <Popover side="bottom" align="end">
272
- <PopoverTrigger>
273
- <PressableHighlight
274
- focusRing
275
- accessibilityRole="button"
276
- accessibilityLabel={`Tasks: ${done} of ${tasks.length} done`}
277
- style={(state) => [
278
- { paddingHorizontal: 8, paddingVertical: 6, borderRadius: 8, minWidth: 66 },
279
- state.hovered ? { backgroundColor: colors.zinc[50] } : null,
280
- ]}
281
- >
282
- <ProgressBar compact value={done} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
283
- </PressableHighlight>
284
- </PopoverTrigger>
285
- <PopoverContent style={{ width: 440 }}>
286
- <TasksChecklist tasks={tasks} onChange={onChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} />
287
- </PopoverContent>
288
- </Popover>
289
- );
290
- }
291
-
292
- // The call-log vocabulary — every entry is an OUTCOME (the telesales pattern:
293
- // status flows from the outcome, a call-back carries its follow-up date).
294
- type Outcome = "connected" | "callback" | "declined" | "noanswer";
295
- const OUTCOME_OPTS: { value: Outcome; label: string }[] = [
296
- { value: "connected", label: "Connected" },
297
- { value: "callback", label: "Call back" },
298
- { value: "declined", label: "Declined" },
299
- { value: "noanswer", label: "No answer" },
300
- ];
301
- const OUTCOME_META: Record<Outcome, { label: string; icon: "phone" | "message-square"; color: string }> = {
302
- connected: { label: "Call — connected", icon: "phone", color: colors.emerald[500] },
303
- callback: { label: "Call — call back", icon: "phone", color: colors.amber[500] },
304
- declined: { label: "Call — declined", icon: "phone", color: colors.red[500] },
305
- noanswer: { label: "Call — no answer", icon: "phone", color: colors.zinc[400] },
306
- };
307
99
 
308
100
  type TrangThai = "dangxuly" | "chobosung" | "hoantat";
309
101
  type Tab = "all" | TrangThai;
@@ -375,7 +167,6 @@ const COLUMNS: TableColumn[] = [
375
167
  { key: "ma", label: "Record ID", width: 112, sortable: true },
376
168
  { key: "khach", label: "Customer", flex: 1, sortable: true, priority: 1 },
377
169
  { key: "trangThai", label: "Status", width: 112, sortable: true, priority: 3 },
378
- { key: "viec", label: "Tasks", width: 82, priority: 4 },
379
170
  { key: "phi", label: "Fee", width: 116, align: "right", sortable: true, priority: 2 },
380
171
  { key: "ngayNhan", label: "Received", width: 72, sortable: true, priority: 5 },
381
172
  ];
@@ -397,7 +188,7 @@ function menuFor(r: HoSo, daThu: boolean): ActionMenuItem[] {
397
188
  ];
398
189
  }
399
190
 
400
- function HoSoRow({ hs, daThu, tasks, onTasksChange, suggestions, onDismissSuggestion, selected, marked, selectable, onToggle, onPress }: { hs: HoSo; daThu: boolean; tasks: RecTask[]; onTasksChange: (next: RecTask[]) => void; suggestions: string[]; onDismissSuggestion: (label: string) => void; selected: boolean; marked: boolean; selectable: boolean; onToggle: (on: boolean) => void; onPress: () => void }) {
191
+ function HoSoRow({ hs, daThu, selected, marked, selectable, onToggle, onPress }: { hs: HoSo; daThu: boolean; selected: boolean; marked: boolean; selectable: boolean; onToggle: (on: boolean) => void; onPress: () => void }) {
401
192
  return (
402
193
  <TableRow
403
194
  onPress={onPress}
@@ -421,9 +212,6 @@ function HoSoRow({ hs, daThu, tasks, onTasksChange, suggestions, onDismissSugges
421
212
  <TableCell>
422
213
  <Badge variant="dot" label={TRANG_THAI[hs.trangThai].label} color={TRANG_THAI[hs.trangThai].color} />
423
214
  </TableCell>
424
- <TableCell>
425
- <TasksPeek tasks={tasks} onChange={onTasksChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} />
426
- </TableCell>
427
215
  <TableCell>
428
216
  <View style={{ gap: 2, alignItems: "flex-end" }}>
429
217
  <Text size="sm" tabular>{formatMoney(hs.phi)}</Text>
@@ -445,547 +233,15 @@ function HoSoRow({ hs, daThu, tasks, onTasksChange, suggestions, onDismissSugges
445
233
  // the status as a quiet suffix → divider → ONE emphasized closing number — the
446
234
  // same shape at popover density). Keyed by id from the caller so per-record
447
235
  // state resets as ◀ ▶ steps the sequence.
448
- function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, onDismissSuggestion, onClose }: { hs: HoSo; daThu: boolean; onPaid: () => void; tasks: RecTask[]; onTasksChange: (next: RecTask[]) => void; suggestions: string[]; onDismissSuggestion: (label: string) => void; onClose: () => void }) {
449
- const doc = docFor(hs, daThu);
450
- const { navigate } = useScreenRouter();
451
- // A record CREATED this session (not in the seed set) opens with only what
452
- // creation gave it — no fabricated June history; seeded records carry the
453
- // full worked mock.
454
- const isSeed = HO_SO.some((r) => r.ma === hs.ma);
455
- // Linked records — in a real app, records related via a link field; mocked
456
- // as two sibling rows here (their own customers show on each row).
457
- const linked = isSeed ? HO_SO.filter((r) => r.ma !== hs.ma).slice(0, 2) : [];
458
- const [khach, setKhach] = useState(hs.khach);
459
- const [dienThoai, setDienThoai] = useState(hs.dienThoai);
460
- const [assignee, setAssignee] = useState<string | null>(memberIdOf(hs.phuTrach));
461
- const [ngayNhan, setNgayNhan] = useState(() => isoReceived(hs.ngayNhan));
462
- const [due, setDue] = useState("");
463
- const [priority, setPriority] = useState(hs.trangThai === "chobosung" ? "urgent" : "standard");
464
- const [declared, setDeclared] = useState<number | null>(isSeed ? 25_000_000 : null);
465
- const [tags, setTags] = useState<string[]>(isSeed ? (hs.trangThai === "hoantat" ? ["corporate"] : ["corporate", "courier"]) : []);
466
- const [files, setFiles] = useState<DisplayFile[]>(isSeed ? [sampleDoc("id-scan", "#3b82f6"), sampleDoc("application-form", "#10b981")] : []);
467
- // THE standard files intake — one handler, three ways in: the section's Add
468
- // CTA, a drag anywhere onto the Files section, and Ctrl/Cmd+V — the last two
469
- // both ride the section's `<FileDropTarget paste>` below. Every record surface
470
- // carries all three; the user should never have to hunt for a dropzone.
471
- const addFiles = (picked: File[]) => setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
472
- // ── the case's money: CHARGES owed vs RECEIPTS received — outstanding is
473
- // the difference. Recording a payment appends a receipt; everything
474
- // (bar, group sums, the closing number) recomputes from the two lists.
475
- const charges = isSeed
476
- ? [
477
- { id: "f1", item: "Service fee", amount: hs.phi, basis: "Fixed tariff, type A", chargedOn: "03/06", by: "Sarah Chen", ref: "" },
478
- { id: "f2", item: "Notarization", amount: 350_000, basis: "Per notarized copy × 2", chargedOn: "04/06", by: "David Park", ref: "" },
479
- { id: "f3", item: "Government levy", amount: 1_200_000, basis: "State fee schedule 2026", chargedOn: "04/06", by: "System", ref: "LEVY-2026-118" },
480
- ]
481
- : hs.phi > 0
482
- ? [{ id: "f1", item: "Service fee", amount: hs.phi, basis: "Fixed tariff, type A", chargedOn: "Today", by: "Sarah Chen", ref: "" }]
483
- : [];
484
- const [receipts, setReceipts] = useState(() =>
485
- isSeed
486
- ? [
487
- { id: "r1", date: hs.ngayNhan, method: "Cash", note: "Disbursements", amount: 1_550_000 },
488
- ...(daThu ? [{ id: "r2", date: hs.ngayThu ?? "Today", method: "Bank transfer", note: "Service fee", amount: hs.phi }] : []),
489
- ]
490
- : [],
491
- );
492
- const totalCharged = charges.reduce((t, c) => t + c.amount, 0);
493
- const received = receipts.reduce((t, r) => t + r.amount, 0);
494
- const outstanding = Math.max(0, totalCharged - received);
495
- // the record-payment stage gate (1–2 fields → a popover, per the gate law)
496
- const [payOpen, setPayOpen] = useState(false);
497
- const [payAmount, setPayAmount] = useState<number | null>(null);
498
- const [payMethod, setPayMethod] = useState("transfer");
499
- const PAY_METHODS: PickerOption[] = [
500
- { value: "cash", label: "Cash" },
501
- { value: "transfer", label: "Bank transfer" },
502
- { value: "card", label: "Card" },
503
- ];
504
- const recordPayment = () => {
505
- if (!payAmount || payAmount <= 0) return;
506
- setReceipts((prev) => [
507
- ...prev,
508
- { id: `r_${prev.length + 1}`, date: "Today", method: PAY_METHODS.find((m) => m.value === payMethod)?.label ?? payMethod, note: "Payment", amount: payAmount },
509
- ]);
510
- // Settling the ledger flips the record's paid state at the PAGE level —
511
- // the register row (Unpaid tag, Print, menu) updates live.
512
- if (totalCharged - (received + payAmount) <= 0) onPaid();
513
- setPayOpen(false);
514
- };
515
- const remainingTasks = tasks.filter((t) => !t.done).length;
516
- // ── the handoff dialog — never a blocked CTA: pressing Hand off opens the
517
- // dialog (receiving assignee + a note for the next desk); incomplete tasks
518
- // WARN inside, they don't block.
519
- const [handoffOpen, setHandoffOpen] = useState(false);
520
- const [handoffAssignee, setHandoffAssignee] = useState<string | null>("m2");
521
- const [handoffNote, setHandoffNote] = useState("");
522
-
523
- // ── activity — the interaction log (the telesales call-log shape): every
524
- // entry is an OUTCOME; a call-back asks for its follow-up date; the composer
525
- // sits ABOVE the history, always visible.
526
- // Attachments ride the log entry: picked in the composer (removable
527
- // thumbnails), then shown as an expandable thumbnail grid ON the entry —
528
- // pressing one opens the shared gallery.
529
- const [attached, setAttached] = useState<DisplayFile[]>([]);
530
- const [galleryFiles, setGalleryFiles] = useState<DisplayFile[]>([]);
531
- const [galleryIdx, setGalleryIdx] = useState<number | null>(null);
532
- const attachmentDetails = (docs: DisplayFile[]) => (
533
- <FileThumbnailGrid
534
- files={docs}
535
- itemSize={64}
536
- onFilePress={(f) => {
537
- setGalleryFiles(docs);
538
- setGalleryIdx(docs.findIndex((x) => x.id === f.id));
539
- }}
540
- />
541
- );
542
- const [logOutcome, setLogOutcome] = useState<Outcome | "">("");
543
- const [logNote, setLogNote] = useState("");
544
- const [followUp, setFollowUp] = useState<string | null>(null);
545
- const [events, setEvents] = useState<TimelineItem[]>(() => isSeed ? [
546
- { id: "e1", icon: "phone", iconColor: colors.amber[500], label: "Call — call back", description: "Asked for the signed contract; follow up 08/06", right: <Text size="xs" color="muted">04/06</Text> },
547
- { id: "e2", icon: "credit-card", iconColor: colors.emerald[500], label: "Payment received — 1.550.000 ₫", description: "Cash for disbursements advance", right: <Text size="xs" color="muted">03/06</Text> },
548
- { id: "e3", icon: "phone", iconColor: colors.zinc[400], label: "Call — no answer", right: <Text size="xs" color="muted">03/06</Text> },
549
- { id: "e4", icon: "file-text", iconColor: colors.zinc[500], label: "Receipt printed", right: <Text size="xs" color="muted">03/06</Text> },
550
- { id: "e5", icon: "phone", iconColor: colors.emerald[500], label: "Call — connected", description: "Walked through the required documents; 1 file", right: <Text size="xs" color="muted">02/06</Text>, details: attachmentDetails([sampleDoc("checklist", "#8b5cf6")]) },
551
- { id: "e6", icon: "plus", iconColor: colors.blue[500], label: "Record created", right: <Text size="xs" color="muted">02/06</Text> },
552
- ] : [
553
- { id: "e1", icon: "plus", iconColor: colors.blue[500], label: "Record created", right: <Text size="xs" color="muted">Today</Text> },
554
- ]);
555
- // The NOTE is the record — the one required thing. The outcome pill is an
556
- // optional tag (tap the active pill to deselect); follow-up an optional date.
557
- const canLog = logNote.trim() !== "";
558
- const logEvent = () => {
559
- if (!canLog) return;
560
- const meta = logOutcome === "" ? null : OUTCOME_META[logOutcome];
561
- const note = logNote.trim();
562
- const docs = attached;
563
- // With an outcome the note goes in the description; without one the note IS
564
- // the label — either way follow-up and file count always survive.
565
- const parts = [
566
- meta ? note : null,
567
- followUp ? `follow up ${followUp}` : null,
568
- docs.length > 0 ? `${docs.length} ${docs.length === 1 ? "file" : "files"}` : null,
569
- ].filter((x): x is string => x !== null);
570
- setEvents((prev) => [
571
- {
572
- id: `e_${prev.length + 1}`,
573
- icon: meta ? meta.icon : "message-square",
574
- iconColor: meta ? meta.color : colors.blue[500],
575
- label: meta ? meta.label : note,
576
- description: parts.join("; ") || undefined,
577
- right: <Text size="xs" color="muted">Today</Text>,
578
- details: docs.length > 0 ? attachmentDetails(docs) : undefined,
579
- },
580
- ...prev,
581
- ]);
582
- setLogNote("");
583
- setFollowUp(null);
584
- setLogOutcome("");
585
- setAttached([]);
586
- };
587
- return (
588
- <>
589
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
590
- <View style={{ gap: 8 }}>
591
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
592
- <Text size="lg" weight="semibold" tabular>{hs.ma}</Text>
593
- <Badge label={TRANG_THAI[hs.trangThai].label} color={TRANG_THAI[hs.trangThai].color} />
594
- </View>
595
- <Text size="sm" color="muted">{`Received ${hs.ngayNhan} by ${hs.phuTrach}`}</Text>
596
- </View>
597
- <Divider />
598
- {/* DETAILS — every field type in the inline vocabulary, on ONE
599
- DetailTable grid: text, member, dates (plain + optional-time),
600
- select-with-dots, money number, a multi-value TAG field, a
601
- system-owned static. The chips mark what's editable. */}
602
- <Section>
603
- <SectionHeading>
604
- <SectionHeadingTitle>Details</SectionHeadingTitle>
605
- </SectionHeading>
606
- <DetailTable labelWidth={120}>
607
- <DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
608
- {/* A verb about the VALUE rides the FIELD — `InlineButton` in the
609
- editor's `actions`, never a column beside it. Passed
610
- unconditionally and disabled when there is nothing to act on: a
611
- verb that appears once the value is filled resizes the field as
612
- the user types. */}
613
- <DetailRow label="Phone">
614
- <InlineTextInput
615
- value={dienThoai}
616
- onSave={persist(setDienThoai)}
617
- accessibilityLabel="Phone"
618
- actions={<InlineButton title="Call" accessibilityLabel="Call this number" disabled={!dienThoai} onPress={() => {}} />}
619
- />
620
- </DetailRow>
621
- <DetailRow label="Assignee">
622
- <InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
623
- </DetailRow>
624
- <DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
625
- {/* The verb rides the FIELD, like every other value verb — including
626
- on a popover editor: the field exposes its outer box separately
627
- from the control inside it, so the calendar still opens at the
628
- field's own width. */}
629
- <DetailRow label="Due">
630
- <InlineDatePicker
631
- value={due}
632
- optionalTime
633
- onSave={persist(setDue)}
634
- placeholder="Set due date…"
635
- accessibilityLabel="Due date"
636
- actions={<InlineButton title="Today" accessibilityLabel="Set due date to today" onPress={() => void persist(setDue)(toISODate(new Date()))} />}
637
- />
638
- </DetailRow>
639
- <DetailRow label="Priority">
640
- <InlineSelect
641
- value={priority}
642
- onSave={persist(setPriority)}
643
- options={PRIORITY_OPTS}
644
- accessibilityLabel="Priority"
645
- renderOptionContent={(o) => (
646
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
647
- <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PRIORITY_DOT[o.value] }} />
648
- <Text size="sm">{o.label}</Text>
649
- </View>
650
- )}
651
- />
652
- </DetailRow>
653
- <DetailRow label="Declared value">
654
- <InlineNumberInput value={declared} onSave={persist(setDeclared)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Declared value" />
655
- </DetailRow>
656
- <DetailRow label="Tags">
657
- <InlineSelect
658
- multi
659
- value={tags}
660
- onSave={persist(setTags)}
661
- options={TAG_OPTIONS}
662
- renderSelected={renderTagBadge}
663
- placeholder="Add tags…"
664
- accessibilityLabel="Tags"
665
- />
666
- </DetailRow>
667
- {/* A `Badge` is not a verb and never was a column — it qualifies the
668
- VALUE, so it sits beside it inside the value cell. */}
669
- <DetailRow label="Record ID" flat>
670
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
671
- <InlineStatic value={hs.ma} tabular />
672
- <Badge label="System" color="zinc" />
673
- </View>
674
- </DetailRow>
675
- </DetailTable>
676
- </Section>
677
- <Divider />
678
- {/* TASKS — the Record template's section shape: heading + the compact
679
- meter, the shared checklist below (capture at the end);
680
- the same state feeds the register column + its peek. */}
681
- <Section>
682
- <SectionHeading>
683
- <SectionHeadingTitle>Tasks</SectionHeadingTitle>
684
- {tasks.length > 0 ? (
685
- <View style={{ width: 120 }}>
686
- <ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
687
- </View>
688
- ) : null}
689
- </SectionHeading>
690
- <TasksChecklist tasks={tasks} onChange={onTasksChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} />
691
- </Section>
692
- <Divider />
693
- {/* LINKED RECORDS — a record surfaces its relatives (the customer's
694
- other cases); pressing one PUSHES its screen inside this drawer
695
- (ScreenRouter), back returns here with scroll preserved. Hidden
696
- while the record has none (a fresh create). */}
697
- {linked.length > 0 ? (
698
- <Section>
699
- <SectionHeading>
700
- <SectionHeadingTitle>Linked records</SectionHeadingTitle>
701
- <SectionHeadingMeta>{`${linked.length} linked`}</SectionHeadingMeta>
702
- </SectionHeading>
703
- <View>
704
- {linked.map((r) => (
705
- <ListItem
706
- key={r.ma}
707
- title={r.ma}
708
- description={r.khach}
709
- right={
710
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
711
- <Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
712
- <Text size="sm" tabular color="muted">{formatMoney(r.phi)}</Text>
713
- <Icon name="chevron-right" size={16} color={colors.zinc[400]} />
714
- </View>
715
- }
716
- onPress={() => navigate(`/record/${r.ma}`)}
717
- />
718
- ))}
719
- </View>
720
- </Section>
721
- ) : null}
722
- <Divider />
723
- {/* FILES — the document CRUD: FileRows (press → gallery preview, ⋯ →
724
- remove) + the section's OWN CTA; an EXPECTED document renders as a
725
- ghost placeholder row with its own chase action.
726
- The whole section is a FileDropTarget with `paste` — drag a file
727
- anywhere onto it, or Ctrl/Cmd+V while focus is in it — the standard
728
- files-intake behavior every record surface should carry. */}
729
- <FileDropTarget onFiles={addFiles} paste>
730
- <Section>
731
- <SectionHeading>
732
- {/* The standard files-section affordance line — drag / paste / click
733
- are otherwise invisible on a FileDropTarget-wrapped section. */}
734
- <SectionHeadingTitle description="Drag, paste, or click to add files">Files</SectionHeadingTitle>
735
- <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta>
736
- <Button
737
- title="Add file"
738
- color="secondary"
739
- onPress={() => {
740
- void pickFiles({ multiple: true }).then((picked) => {
741
- if (picked.length > 0) addFiles(picked);
742
- });
743
- }}
744
- />
745
- </SectionHeading>
746
- <FileRows files={files} onRemove={(f) => setFiles((prev) => prev.filter((x) => x.id !== f.id))} />
747
- <FileRow
748
- name="Signed service contract"
749
- placeholder
750
- meta="Expected — not provided yet"
751
- trailing={<Button title="Request" color="muted" onPress={() => {}} />}
752
- />
753
- </Section>
754
- </FileDropTarget>
755
- <Divider />
756
- {/* the PAYMENT summary — a plain financial STATEMENT: the two sides
757
- of the ledger (charges owed, receipts received), every amount on
758
- ONE right-aligned tabular column, a divider-set emphasized closing
759
- number, and the record-payment popover that appends a receipt.
760
- No bars, no charts — the numbers are the interface. */}
761
- {charges.length > 0 ? (
762
- <Section>
763
- <SectionHeading>
764
- <SectionHeadingTitle>Payment</SectionHeadingTitle>
765
- <SectionHeadingMeta>{`${receipts.length} ${receipts.length === 1 ? "receipt" : "receipts"}`}</SectionHeadingMeta>
766
- </SectionHeading>
767
- {/* the Ledger compound: groups with their sums, peekable fee rows
768
- (press → the fee's particulars in an anchored popover; references
769
- live INSIDE the peek), receipts with meta, one emphasized close */}
770
- <Ledger formatValue={formatMoney}>
771
- <LedgerGroup label="Charges" total={totalCharged}>
772
- {charges.map((c) => (
773
- <LedgerRow
774
- key={c.id}
775
- label={c.item}
776
- value={c.amount}
777
- peek={
778
- <View style={{ gap: 8 }}>
779
- <View style={{ gap: 2 }}>
780
- <Text size="sm" weight="semibold">{c.item}</Text>
781
- <Text size="xs" color="muted">{`Charged ${c.chargedOn} by ${c.by}`}</Text>
782
- </View>
783
- <DetailRow label="Basis" labelSize="xs" labelWidth={72}><Text size="xs">{c.basis}</Text></DetailRow>
784
- <DetailRow label="Amount" labelSize="xs" labelWidth={72}><Text size="xs" tabular>{formatMoney(c.amount)}</Text></DetailRow>
785
- {c.ref ? (
786
- <DetailRow label="Reference" labelSize="xs" labelWidth={72}>
787
- <Link size="xs" onPress={() => {}} accessibilityLabel={c.ref}>{c.ref}</Link>
788
- </DetailRow>
789
- ) : null}
790
- </View>
791
- }
792
- />
793
- ))}
794
- </LedgerGroup>
795
- <LedgerGroup label="Received" total={received > 0 ? -received : undefined}>
796
- {receipts.map((r) => (
797
- <LedgerRow key={r.id} label={r.note} meta={`${r.date}, ${r.method}`} value={-r.amount} tone="success" />
798
- ))}
799
- </LedgerGroup>
800
- <LedgerTotal label="Outstanding" value={outstanding} tone="danger" zeroLabel="Paid in full" />
801
- </Ledger>
802
- {outstanding > 0 ? (
803
- <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
804
- <Popover
805
- side="top"
806
- align="end"
807
- open={payOpen}
808
- onOpenChange={(o) => {
809
- setPayOpen(o);
810
- if (o) {
811
- setPayAmount(outstanding);
812
- setPayMethod("transfer");
813
- }
814
- }}
815
- >
816
- <PopoverTrigger>
817
- <Button title="Record payment" color="secondary" />
818
- </PopoverTrigger>
819
- <PopoverContent style={{ width: 300 }}>
820
- <View style={{ gap: 12 }}>
821
- <View style={{ gap: 2 }}>
822
- <Text size="sm" weight="semibold">Record a payment</Text>
823
- <Text size="xs" color="muted">Prefilled with the outstanding balance.</Text>
824
- </View>
825
- <FormField label="Amount">
826
- <NumberInput value={payAmount} onValueChange={(v) => setPayAmount(v ?? null)} min={0} accessibilityLabel="Payment amount" />
827
- </FormField>
828
- <FormField label="Method">
829
- <Picker options={PAY_METHODS} value={payMethod} onValueChange={setPayMethod} accessibilityLabel="Payment method" />
830
- </FormField>
831
- <PopoverFooter>
832
- <Button title="Record" color="primary" disabled={!payAmount || payAmount <= 0} onPress={recordPayment} />
833
- </PopoverFooter>
834
- </View>
835
- </PopoverContent>
836
- </Popover>
837
- </View>
838
- ) : null}
839
- </Section>
840
- ) : null}
841
- <Divider />
842
- {/* ACTIVITY — the interaction composer (the CRM touch-log shape): the
843
- NOTE is the record and the one requirement; the outcome is an
844
- optional PILL (tap the active one to deselect); Follow up an
845
- always-visible optional date; the primary Log says WHY when
846
- disabled. The labelled history sits below, newest first. */}
847
- <Section>
848
- <SectionHeading>
849
- <SectionHeadingTitle>Activity</SectionHeadingTitle>
850
- <SectionHeadingMeta>{`${events.length} events`}</SectionHeadingMeta>
851
- </SectionHeading>
852
- <View style={{ gap: 10 }}>
853
- <Text size="sm" weight="semibold">Log an interaction</Text>
854
- <ChipGroup
855
- accessibilityLabel="Outcome"
856
- options={OUTCOME_OPTS}
857
- value={logOutcome}
858
- onValueChange={(v) => setLogOutcome(v === logOutcome ? "" : v)}
859
- />
860
- <TextInputField
861
- value={logNote}
862
- onChangeText={setLogNote}
863
- multiline
864
- numberOfLines={2}
865
- autoGrow
866
- placeholder="What was said?"
867
- accessibilityLabel="Interaction note"
868
- />
869
- {attached.length > 0 ? (
870
- <FileThumbnailGrid
871
- files={attached}
872
- itemSize={64}
873
- onRemove={(id) => setAttached((prev) => prev.filter((x) => x.id !== id))}
874
- />
875
- ) : null}
876
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
877
- <Text size="sm" color="muted">Follow up</Text>
878
- <View style={{ flex: 1 }}>
879
- <InlineDatePicker value={followUp} onSave={setFollowUp} placeholder="Optional" accessibilityLabel="Follow-up date" />
880
- </View>
881
- </View>
882
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
883
- <Button
884
- title="Attach files"
885
- icon="paperclip"
886
- color="secondary"
887
- onPress={() => {
888
- void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((picked) => {
889
- if (picked.length > 0) setAttached((prev) => [...prev, ...toDisplayFiles(picked)]);
890
- });
891
- }}
892
- />
893
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexShrink: 1 }}>
894
- {!canLog ? <Text size="xs" color="muted">Write what was said to log it</Text> : null}
895
- <Button title="Log" color="primary" disabled={!canLog} onPress={logEvent} />
896
- </View>
897
- </View>
898
- </View>
899
- <View style={{ gap: 6 }}>
900
- <Text size="xs" weight="medium" color="muted">History</Text>
901
- <Timeline items={events} />
902
- </View>
903
- </Section>
904
- <Divider />
905
- {/* the destructive operation sits LAST, apart from the work — the same
906
- DangerZone the Record template closes with */}
907
- <DangerZone
908
- title="Delete record"
909
- description="Permanently remove this record, its charges, receipts, and files. This can't be undone."
910
- >
911
- <Button title="Delete record" color="danger" onPress={() => {}} />
912
- </DangerZone>
913
- </ScrollView>
914
- <FileGalleryModal files={galleryFiles} activeIndex={galleryIdx} onIndexChange={setGalleryIdx} />
915
- {/* pinned footer — the stage's paper + the way into the full record */}
916
- {/* the HANDOFF — never a blocked CTA: pressing it opens the dialog
917
- (assignee + a note for the receiving desk); open tasks WARN there */}
918
- <DrawerFooter>
919
- {remainingTasks > 0 ? (
920
- <Text size="xs" color="muted" style={{ flex: 1 }}>
921
- {`${remainingTasks} ${remainingTasks === 1 ? "open task" : "open tasks"}`}
922
- </Text>
923
- ) : (
924
- <View style={{ flex: 1 }} />
925
- )}
926
- <Button title={doc.title} icon={doc.icon} color="secondary" onPress={() => {}} />
927
- <Button title="Hand off to Operations" color="primary" onPress={() => setHandoffOpen(true)} />
928
- </DrawerFooter>
929
- {/* the handoff dialog — initial info for the receiving desk; confirming
930
- moves the record off this desk (the drawer closes: it left your
931
- register) */}
932
- <Dialog width={480} open={handoffOpen} onOpenChange={setHandoffOpen}>
933
- <DialogHeader>
934
- <DialogHeaderTitle>Hand off to Operations</DialogHeaderTitle>
935
- </DialogHeader>
936
- <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
937
- {remainingTasks > 0 ? (
938
- <Callout tone="warning">
939
- <CalloutText>
940
- {`${remainingTasks} ${remainingTasks === 1 ? "task is" : "tasks are"} still open — they carry over to Operations.`}
941
- </CalloutText>
942
- </Callout>
943
- ) : null}
944
- <FormField label="Assign to">
945
- <MemberSelect members={MEMBERS} value={handoffAssignee} onValueChange={setHandoffAssignee} accessibilityLabel="Receiving assignee" />
946
- </FormField>
947
- <FormField label="Note for Operations" optional optionalLabel="Optional">
948
- <TextInputField
949
- value={handoffNote}
950
- onChangeText={setHandoffNote}
951
- multiline
952
- numberOfLines={2}
953
- autoGrow
954
- placeholder="Anything the next desk should know…"
955
- accessibilityLabel="Handoff note"
956
- />
957
- </FormField>
958
- </View>
959
- <DialogFooter>
960
- <Button title="Cancel" color="secondary" onPress={() => setHandoffOpen(false)} />
961
- <Button
962
- title="Hand off"
963
- color="primary"
964
- disabled={handoffAssignee === null}
965
- onPress={() => {
966
- setHandoffOpen(false);
967
- onClose();
968
- }}
969
- />
970
- </DialogFooter>
971
- </Dialog>
972
- </>
973
- );
974
- }
975
-
976
236
  /** The drawer shell that READS the navigation stack: at the root it titles the
977
237
  * customer and offers the ◀ ▶ sequence; on a pushed screen the header swaps
978
238
  * to a BACK button + the pushed case id (the proper home for back — drawer
979
239
  * chrome, not scroll content), and the sequence hides (stepping the root
980
240
  * record from inside a linked one would be disorienting). */
981
- function RecordDrawer({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, onDismissSuggestion, onClose, onPrev, onNext, position }: {
241
+ function RecordDrawer({ hs, daThu: _daThu, onPaid: _onPaid, onClose, onPrev, onNext, position }: {
982
242
  hs: HoSo;
983
243
  daThu: boolean;
984
244
  onPaid: () => void;
985
- tasks: RecTask[];
986
- onTasksChange: (next: RecTask[]) => void;
987
- suggestions: string[];
988
- onDismissSuggestion: (label: string) => void;
989
245
  onClose: () => void;
990
246
  onPrev?: () => void;
991
247
  onNext?: () => void;
@@ -1003,7 +259,12 @@ function RecordDrawer({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, on
1003
259
  <Text size="md" weight="semibold" tabular>{params.ma ?? ""}</Text>
1004
260
  </View>
1005
261
  ) : (
1006
- hs.khach
262
+ // The record CODE, not the customer name. The body is `TplRecord`, whose
263
+ // customer is its own mock — titling this `hs.khach` put "Michael Torres"
264
+ // above a record reading "Harbor Freight Lines", i.e. one surface
265
+ // disagreeing with itself. The code is the one identity both sides share,
266
+ // because it is the prop the drawer passes down.
267
+ <Text size="md" weight="semibold" tabular>{hs.ma}</Text>
1007
268
  )
1008
269
  }
1009
270
  onPrev={canGoBack ? undefined : onPrev}
@@ -1011,7 +272,13 @@ function RecordDrawer({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, on
1011
272
  position={canGoBack ? undefined : position}
1012
273
  >
1013
274
  <Screen route="">
1014
- <HoSoWorkspace hs={hs} daThu={daThu} onPaid={onPaid} tasks={tasks} onTasksChange={onTasksChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} onClose={onClose} />
275
+ {/* THE RECORD SURFACE, not a copy of it. tpl_record renders in `drawer`
276
+ chrome: same sections, same order, same rules — the rail and the docked
277
+ discussion collapse (a Drawer has no gutters), and the thread joins the
278
+ reading order as its last section. Rendering the component is what keeps
279
+ the two identical; the hand-built workspace that used to live here is
280
+ how they drifted — a checklist on this side, a pipeline on that one. */}
281
+ <TplRecord chrome="drawer" code={hs.ma} />
1015
282
  </Screen>
1016
283
  <Screen route="/record/:ma">{(p) => <LinkedRecordScreen ma={p.ma} />}</Screen>
1017
284
  </Drawer>
@@ -1515,21 +782,10 @@ export function TplItemList() {
1515
782
  // ONE task state per record — the register column, its peek popover, and the
1516
783
  // drawer all read and write the same list. Paid state lifts the same way:
1517
784
  // settling the ledger in the drawer flips the row's Unpaid/Print/menu live.
1518
- const [taskMap, setTaskMap] = useState<Record<string, RecTask[]>>({});
1519
- const tasksOf = (hs: HoSo) => taskMap[hs.ma] ?? seedTasks(hs);
1520
- const setTasksOf = (hs: HoSo, next: RecTask[]) => setTaskMap((prev) => ({ ...prev, [hs.ma]: next }));
1521
785
  const [paidMap, setPaidMap] = useState<Record<string, boolean>>({});
1522
786
  const daThuOf = (r: HoSo) => paidMap[r.ma] ?? r.daThu;
1523
- const [dismissedMap, setDismissedMap] = useState<Record<string, string[]>>({});
1524
- const suggestionsOf = (r: HoSo) => HANDOFF_TASKS.filter((l) => !(dismissedMap[r.ma] ?? []).includes(l));
1525
- const dismissSuggestion = (r: HoSo, label: string) =>
1526
- setDismissedMap((prev) => {
1527
- const cur = prev[r.ma] ?? [];
1528
- return cur.includes(label) ? prev : { ...prev, [r.ma]: [...cur, label] };
1529
- });
1530
787
  // The register rows are state: the create dialog PREPENDS a real row and
1531
- // opens its drawer — create-then-refine (the new record's checklist starts
1532
- // EMPTY; the commons wait as ghost suggestions).
788
+ // opens its drawer — create-then-refine.
1533
789
  const [rows, setRows] = useState<HoSo[]>(HO_SO);
1534
790
  const nextMa = useRef(64);
1535
791
  const addRow = (khach: string, dienThoai: string, phi: number): string => {
@@ -1546,7 +802,6 @@ export function TplItemList() {
1546
802
  phuTrach: "Sarah Chen",
1547
803
  };
1548
804
  setRows((prev) => [row, ...prev]);
1549
- setTaskMap((prev) => ({ ...prev, [ma]: [] }));
1550
805
  return ma;
1551
806
  };
1552
807
  // Reveal what was just created: clear every filter that could hide the new
@@ -1766,7 +1021,7 @@ export function TplItemList() {
1766
1021
  }
1767
1022
  >
1768
1023
  {pageRows.map((r) => (
1769
- <HoSoRow key={r.ma} hs={r} daThu={daThuOf(r)} tasks={tasksOf(r)} onTasksChange={(next) => setTasksOf(r, next)} suggestions={suggestionsOf(r)} onDismissSuggestion={(l) => dismissSuggestion(r, l)} selected={r.ma === openMa} marked={sel.has(r.ma)} selectable={isSelectable(r)} onToggle={(on) => sel.toggle(r.ma, on)} onPress={() => setOpenMa(r.ma)} />
1024
+ <HoSoRow key={r.ma} hs={r} daThu={daThuOf(r)} selected={r.ma === openMa} marked={sel.has(r.ma)} selectable={isSelectable(r)} onToggle={(on) => sel.toggle(r.ma, on)} onPress={() => setOpenMa(r.ma)} />
1770
1025
  ))}
1771
1026
  </Table>
1772
1027
  {pageRows.length === 0 ? (
@@ -1814,12 +1069,8 @@ export function TplItemList() {
1814
1069
  <ScreenRouter key={openRow.ma}>
1815
1070
  <RecordDrawer
1816
1071
  hs={openRow}
1817
- suggestions={suggestionsOf(openRow)}
1818
- onDismissSuggestion={(l) => dismissSuggestion(openRow, l)}
1819
1072
  daThu={daThuOf(openRow)}
1820
1073
  onPaid={() => setPaidMap((prev) => ({ ...prev, [openRow.ma]: true }))}
1821
- tasks={tasksOf(openRow)}
1822
- onTasksChange={(next) => setTasksOf(openRow, next)}
1823
1074
  onClose={() => setOpenMa(null)}
1824
1075
  onPrev={seqIndex > 0 ? () => setOpenMa(pageRows[seqIndex - 1].ma) : undefined}
1825
1076
  onNext={seqIndex >= 0 && seqIndex < pageRows.length - 1 ? () => setOpenMa(pageRows[seqIndex + 1].ma) : undefined}