@lotics/ui 8.0.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +177 -70
  2. package/examples/tpl_allocate.tsx +2 -2
  3. package/examples/tpl_attendance.tsx +2 -2
  4. package/examples/tpl_calendar.tsx +1 -1
  5. package/examples/tpl_dashboard.tsx +1 -1
  6. package/examples/tpl_item_list.tsx +1015 -124
  7. package/examples/tpl_pick.tsx +3 -3
  8. package/examples/tpl_pivot.tsx +1 -1
  9. package/examples/tpl_record.tsx +1354 -0
  10. package/examples/tpl_report.tsx +7 -7
  11. package/examples/tpl_rollup.tsx +6 -6
  12. package/examples/tpl_shifts.tsx +2 -2
  13. package/examples/tpl_statements.tsx +221 -0
  14. package/examples/tpl_stock.tsx +7 -7
  15. package/examples/tpl_task_board.tsx +16 -13
  16. package/examples/tpl_tasks.tsx +15 -28
  17. package/examples/tpl_tower.tsx +2 -2
  18. package/package.json +8 -7
  19. package/src/capture_row.tsx +59 -0
  20. package/src/checklist.tsx +104 -0
  21. package/src/chip.tsx +12 -3
  22. package/src/detail_row.tsx +137 -10
  23. package/src/inline_date_picker.tsx +8 -3
  24. package/src/inline_edit.tsx +40 -10
  25. package/src/inline_member_select.tsx +3 -0
  26. package/src/inline_number_input.tsx +5 -2
  27. package/src/inline_select.tsx +8 -3
  28. package/src/inline_tag_select.tsx +140 -0
  29. package/src/inline_text_input.tsx +5 -2
  30. package/src/inline_time_picker.tsx +5 -2
  31. package/src/ledger.tsx +220 -0
  32. package/src/locale.tsx +25 -0
  33. package/src/page_header.tsx +0 -2
  34. package/src/popover_nav.tsx +40 -0
  35. package/src/progress_bar.tsx +32 -1
  36. package/src/record_summary.tsx +101 -0
  37. package/src/section_heading.tsx +16 -8
  38. package/src/suggestion_chip.tsx +47 -0
  39. package/src/trend_footer.tsx +3 -1
  40. package/src/use_screen_size.ts +1 -1
  41. package/src/use_section_nav.test.ts +69 -0
  42. package/src/use_section_nav.ts +59 -0
  43. package/examples/tpl_billing.tsx +0 -344
  44. package/examples/tpl_detail.tsx +0 -232
  45. package/examples/tpl_directory.tsx +0 -260
  46. package/examples/tpl_intake.tsx +0 -206
  47. package/examples/tpl_order.tsx +0 -482
  48. package/examples/tpl_quick.tsx +0 -211
  49. package/examples/tpl_record_plain.tsx +0 -259
  50. package/examples/tpl_settings.tsx +0 -178
  51. package/examples/tpl_timeline.tsx +0 -244
  52. package/examples/tpl_wizard.tsx +0 -223
  53. package/src/animation_horizontal_slide.tsx +0 -75
  54. package/src/form_time_picker.tsx +0 -22
  55. package/src/highlighted_text.tsx +0 -92
  56. package/src/menu_title.tsx +0 -15
  57. package/src/pager_view.tsx +0 -167
  58. package/src/popover_header.tsx +0 -38
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useState, useRef } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors, type ColorName } from "@lotics/ui/colors";
@@ -8,13 +8,21 @@ import { Badge } from "@lotics/ui/badge";
8
8
  import { Button } from "@lotics/ui/button";
9
9
  import { CardFooter } from "@lotics/ui/card";
10
10
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
11
- import { DetailRow } from "@lotics/ui/detail_row";
11
+ import { CheckCircle } from "@lotics/ui/check_circle";
12
+ import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
12
13
  import { Divider } from "@lotics/ui/divider";
13
14
  import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
14
15
  import { EmptyState } from "@lotics/ui/empty_state";
15
16
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
16
17
  import { FormField } from "@lotics/ui/form_field";
17
- import { type IconName } from "@lotics/ui/icon";
18
+ import { Icon, type IconName } from "@lotics/ui/icon";
19
+ import { IconButton } from "@lotics/ui/icon_button";
20
+ import { ListItem } from "@lotics/ui/list_item";
21
+ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
22
+ import { CaptureRow } from "@lotics/ui/capture_row";
23
+ import { Checklist, ChecklistRow } from "@lotics/ui/checklist";
24
+ import { SuggestionChip } from "@lotics/ui/suggestion_chip";
25
+ import { Screen, ScreenRouter, useScreenRouter } from "@lotics/ui/screen_router";
18
26
  import { SummaryLine } from "@lotics/ui/summary_line";
19
27
  import { NumberInput } from "@lotics/ui/number_input";
20
28
  import { Pagination } from "@lotics/ui/pagination";
@@ -26,12 +34,39 @@ import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
26
34
  import { OptionList } from "@lotics/ui/option_list";
27
35
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
28
36
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
29
- import type { PickerOption } from "@lotics/ui/picker";
37
+ import { Picker, type PickerOption } from "@lotics/ui/picker";
30
38
  import { formatMoney } from "@lotics/ui/format_money";
39
+ import { toISODate } from "@lotics/ui/format_date";
40
+ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
41
+ import { InlineNumberInput } from "@lotics/ui/inline_number_input";
42
+ import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
43
+ import type { MemberSelectMember } from "@lotics/ui/member_select";
44
+ import { OptionBadge, type OptionValue } from "@lotics/ui/option_badge";
45
+ import { FileRows } from "@lotics/ui/file_rows";
46
+ import { FileRow } from "@lotics/ui/file_row";
47
+ import { pickFiles } from "@lotics/ui/file_picker";
48
+ import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
49
+ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
50
+ import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
51
+ import { ProgressBar } from "@lotics/ui/progress_bar";
52
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
53
+ import { MemberSelect } from "@lotics/ui/member_select";
54
+ import { Callout, CalloutText } from "@lotics/ui/callout";
55
+ import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
56
+ import { Link } from "@lotics/ui/link";
57
+ import { TextInputField } from "@lotics/ui/text_input_field";
58
+ import { InlineTagSelect } from "@lotics/ui/inline_tag_select";
59
+ import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle } from "@lotics/ui/section_heading";
60
+ import { DangerZone } from "@lotics/ui/danger_zone";
61
+ import { ChipGroup } from "@lotics/ui/chip_group";
62
+ import type { DisplayFile } from "@lotics/ui/file_thumbnail";
63
+ import { InlineSelect } from "@lotics/ui/inline_select";
64
+ import { InlineStatic } from "@lotics/ui/inline_static";
65
+ import { InlineTextInput } from "@lotics/ui/inline_text_input";
31
66
  import { useSelection } from "@lotics/ui/use_selection";
32
67
 
33
68
  // ─────────────────────────────────────────────────────────────────────────────
34
- // Template · Item List — THE canonical register, and the ONE work-execution list
69
+ // Template · Register — THE canonical register, and the ONE work-execution list
35
70
  // shape. A flat, paginated, SELECTABLE list of records: one toolbar row (search +
36
71
  // consolidated filters LEFT, New CTA RIGHT) · a light SummaryLine of the filtered
37
72
  // view · a sortable, Divider-separated register where every row has a leading
@@ -39,7 +74,7 @@ import { useSelection } from "@lotics/ui/use_selection";
39
74
  // gets a DISABLED one), a per-row PRINT action button, and a ⋯ overflow (press the
40
75
  // row = the sequenced workspace Drawer) · a select-all band · footer totals +
41
76
  // Pagination · a FloatingActionBar for the bulk action over the ticked rows. The
42
- // example is a case-document register, but this one shape subsumes the old
77
+ // example is a documents-workflow register, but this one shape subsumes the old
43
78
  // approvals / dispatch / batch / run screens — register, per-row action, gated
44
79
  // selection, and act-on-many all in one.
45
80
  // ─────────────────────────────────────────────────────────────────────────────
@@ -51,6 +86,214 @@ const CUSTOMER_OPTIONS: PickerOption[] = [
51
86
  "Michael Torres", "Anna Whitfield", "Robert Hayes", "Karen Mitchell",
52
87
  ].map((name) => ({ value: name, label: name }));
53
88
 
89
+ // The assignable roster — an app feeds `useMembers()` here.
90
+ const MEMBERS: MemberSelectMember[] = [
91
+ { id: "m1", name: "Sarah Chen" },
92
+ { id: "m2", name: "David Park" },
93
+ { id: "m3", name: "Maria Lopez" },
94
+ { id: "m4", name: "James Walker" },
95
+ ];
96
+ const memberIdOf = (name: string) => MEMBERS.find((m) => m.name === name)?.id ?? null;
97
+
98
+ const PRIORITY_OPTS = [
99
+ { value: "standard", label: "Standard" },
100
+ { value: "urgent", label: "Urgent" },
101
+ ];
102
+ const PRIORITY_DOT: Record<string, string> = { standard: colors.zinc[400], urgent: colors.amber[500] };
103
+
104
+ const RECORD_TAGS: Record<string, OptionValue> = {
105
+ corporate: { key: "corporate", label: "Corporate", color: "blue" },
106
+ courier: { key: "courier", label: "Courier", color: "amber" },
107
+ vip: { key: "vip", label: "VIP", color: "emerald" },
108
+ };
109
+ const TAG_OPTIONS = Object.values(RECORD_TAGS).map((t) => ({ value: String(t.key), label: t.label }));
110
+ const renderTagBadge = (o: { value: string; label?: string }) => (
111
+ <OptionBadge value={RECORD_TAGS[o.value] ?? { key: o.value, label: o.label ?? o.value, color: null }} variant="dot" />
112
+ );
113
+
114
+ // Seeded documents preview for real (SVG data URIs); picked files map to
115
+ // local object URLs — a host wires its own upload here.
116
+ const sampleDoc = (label: string, fill: string): DisplayFile => ({
117
+ id: `seed-${label}`,
118
+ filename: `${label}.png`,
119
+ mimeType: "image/png",
120
+ 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>`),
121
+ });
122
+ let fileSeq = 0;
123
+ const toDisplayFiles = (files: File[]): DisplayFile[] =>
124
+ files.map((f) => ({ id: `f_${(fileSeq += 1)}`, filename: f.name, mimeType: f.type || "application/octet-stream", url: URL.createObjectURL(f) }));
125
+
126
+ /** Fake persistence for the inline editors — real apps await their mutation. */
127
+ const persist = <T,>(set: (v: T) => void) => (v: T) =>
128
+ new Promise<void>((resolve) => { setTimeout(() => { set(v); resolve(); }, 300); });
129
+
130
+ // The HANDOFF checklist — this desk's tasks on the record; the register shows
131
+ // done/total per row, and open tasks WARN in the handoff dialog (never block).
132
+ const HANDOFF_TASKS = [
133
+ "Confirm the service scope",
134
+ "Collect the identity documents",
135
+ "Verify the customer's tax code",
136
+ "Collect the service fee",
137
+ "Prepare the handoff summary",
138
+ ];
139
+ interface RecTask {
140
+ id: string;
141
+ label: string;
142
+ done: boolean;
143
+ assignee: string | null;
144
+ due: string;
145
+ }
146
+ // Monotonic id for user-added tasks — a length-derived id would collide after
147
+ // a removal (delete t_1, add → a second t_2) and break keys + patch-by-id.
148
+ let recTaskSeq = 0;
149
+ const newTaskId = () => `t_${(recTaskSeq += 1)}`;
150
+ // Seeded from the record's FACTS so the checklist never contradicts the row:
151
+ // the docs task tracks the Awaiting-docs status, the fee task tracks daThu,
152
+ // the wrap-up tasks clear only on completed records.
153
+ /** "02/06" (dd/MM in the register) → the ISO date the pickers edit. */
154
+ const isoReceived = (ddmm: string) => {
155
+ const [d, m] = ddmm.split("/");
156
+ return `2026-${m}-${d}`;
157
+ };
158
+
159
+ const seedTasks = (hs: HoSo): RecTask[] => {
160
+ const done = [
161
+ true,
162
+ hs.trangThai !== "chobosung",
163
+ hs.trangThai === "hoantat",
164
+ hs.daThu,
165
+ hs.trangThai === "hoantat",
166
+ ];
167
+ return HANDOFF_TASKS.map((label, i) => ({
168
+ id: `${hs.ma}_${i}`,
169
+ label,
170
+ done: done[i] ?? false,
171
+ assignee: i % 2 === 0 ? MEMBERS[i % MEMBERS.length].id : null,
172
+ due: i === 1 && !done[1] ? "2026-07-08" : "",
173
+ }));
174
+ };
175
+
176
+ /** The interactive checklist — the task templates' row vocabulary (sibling
177
+ * CheckCircle + struck transparent InlineTextInput + the dashed-ring capture
178
+ * row). Shared by the register's task peek and the drawer's. */
179
+ function TasksChecklist({ tasks, onChange, suggestions, onDismissSuggestion }: {
180
+ tasks: RecTask[];
181
+ onChange: (next: RecTask[]) => void;
182
+ suggestions: string[];
183
+ onDismissSuggestion: (label: string) => void;
184
+ }) {
185
+ const [draft, setDraft] = useState("");
186
+ const patch = (tid: string, p: Partial<RecTask>) => onChange(tasks.map((x) => (x.id === tid ? { ...x, ...p } : x)));
187
+ const add = () => {
188
+ const label = draft.trim();
189
+ if (!label) return;
190
+ onChange([...tasks, { id: newTaskId(), label, done: false, assignee: null, due: "" }]);
191
+ setDraft("");
192
+ };
193
+ const addSuggested = (label: string) => onChange([...tasks, { id: newTaskId(), label, done: false, assignee: null, due: "" }]);
194
+ const pending = suggestions.filter((l) => !tasks.some((t) => t.label === l));
195
+ return (
196
+ <Checklist>
197
+ {/* NARROW anatomy: the title owns its full line (readable in a drawer
198
+ or peek); assignee + due edit on the indented meta line below */}
199
+ {tasks.map((t) => (
200
+ <ChecklistRow
201
+ key={t.id}
202
+ control={<CheckCircle checked={t.done} onChange={(on) => patch(t.id, { done: on })} accessibilityLabel={t.label} />}
203
+ menu={{
204
+ items: [{ key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => onChange(tasks.filter((x) => x.id !== t.id)) }],
205
+ accessibilityLabel: `Task options: ${t.label}`,
206
+ }}
207
+ meta={
208
+ <>
209
+ <View style={{ width: 150 }}>
210
+ <InlineMemberSelect
211
+ background="transparent"
212
+ members={MEMBERS}
213
+ value={t.assignee}
214
+ onSave={(m) => patch(t.id, { assignee: m })}
215
+ placeholder="Assign…"
216
+ accessibilityLabel={`Assignee · ${t.label}`}
217
+ />
218
+ </View>
219
+ <View style={{ width: 130 }}>
220
+ <InlineDatePicker
221
+ background="transparent"
222
+ value={t.due}
223
+ onSave={(v) => patch(t.id, { due: v })}
224
+ placeholder="Set due…"
225
+ accessibilityLabel={`Due · ${t.label}`}
226
+ />
227
+ </View>
228
+ </>
229
+ }
230
+ >
231
+ <InlineTextInput
232
+ background="transparent"
233
+ value={t.label}
234
+ onSave={(v) => patch(t.id, { label: v })}
235
+ struck={t.done}
236
+ accessibilityLabel="Task title"
237
+ />
238
+ </ChecklistRow>
239
+ ))}
240
+ {/* SUGGESTIONS are pills, not rows — a chip can't be mistaken for a
241
+ task. Tap = materialize; ✕ = dismiss it for this record. */}
242
+ {pending.length > 0 ? (
243
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, paddingTop: 2, paddingBottom: 4 }}>
244
+ {pending.map((label) => (
245
+ <SuggestionChip key={label} label={label} onAdd={() => addSuggested(label)} onDismiss={() => onDismissSuggestion(label)} />
246
+ ))}
247
+ </View>
248
+ ) : null}
249
+ <CaptureRow value={draft} onChangeText={setDraft} onSubmit={add} placeholder="Add a task…" accessibilityLabel="Add a task" />
250
+ </Checklist>
251
+ );
252
+ }
253
+
254
+ /** The task PEEK — done/total + a live mini bar as a pressable trigger; the
255
+ * popover holds the full interactive checklist, so tasks tick from the
256
+ * register without opening the drawer. */
257
+ function TasksPeek({ tasks, onChange, suggestions, onDismissSuggestion }: { tasks: RecTask[]; onChange: (next: RecTask[]) => void; suggestions: string[]; onDismissSuggestion: (label: string) => void }) {
258
+ const done = tasks.filter((t) => t.done).length;
259
+ return (
260
+ <Popover side="bottom" align="end">
261
+ <PopoverTrigger>
262
+ <PressableHighlight
263
+ focusRing
264
+ accessibilityRole="button"
265
+ accessibilityLabel={`Tasks: ${done} of ${tasks.length} done`}
266
+ style={(state) => [
267
+ { paddingHorizontal: 8, paddingVertical: 6, borderRadius: 8, minWidth: 66 },
268
+ state.hovered ? { backgroundColor: colors.zinc[50] } : null,
269
+ ]}
270
+ >
271
+ <ProgressBar compact value={done} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
272
+ </PressableHighlight>
273
+ </PopoverTrigger>
274
+ <PopoverContent style={{ width: 440 }}>
275
+ <TasksChecklist tasks={tasks} onChange={onChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} />
276
+ </PopoverContent>
277
+ </Popover>
278
+ );
279
+ }
280
+
281
+ // The call-log vocabulary — every entry is an OUTCOME (the telesales pattern:
282
+ // status flows from the outcome, a call-back carries its follow-up date).
283
+ type Outcome = "connected" | "callback" | "declined" | "noanswer";
284
+ const OUTCOME_OPTS: { value: Outcome; label: string }[] = [
285
+ { value: "connected", label: "Connected" },
286
+ { value: "callback", label: "Call back" },
287
+ { value: "declined", label: "Declined" },
288
+ { value: "noanswer", label: "No answer" },
289
+ ];
290
+ const OUTCOME_META: Record<Outcome, { label: string; icon: "phone" | "message-square"; color: string }> = {
291
+ connected: { label: "Call — connected", icon: "phone", color: colors.emerald[500] },
292
+ callback: { label: "Call — call back", icon: "phone", color: colors.amber[500] },
293
+ declined: { label: "Call — declined", icon: "phone", color: colors.red[500] },
294
+ noanswer: { label: "Call — no answer", icon: "phone", color: colors.zinc[400] },
295
+ };
296
+
54
297
  type TrangThai = "dangxuly" | "chobosung" | "hoantat";
55
298
  type Tab = "all" | TrangThai;
56
299
 
@@ -73,29 +316,29 @@ interface HoSo {
73
316
  }
74
317
 
75
318
  const HO_SO: HoSo[] = [
76
- { ma: "CS-2026-0041", khach: "Michael Torres", dienThoai: "(555) 384-7560", trangThai: "dangxuly", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "02/06", phuTrach: "Sarah Chen" },
77
- { ma: "CS-2026-0042", khach: "Anna Whitfield", dienThoai: "(555) 220-4158", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "03/06", phuTrach: "Sarah Chen" },
78
- { ma: "CS-2026-0043", khach: "Northwind Packaging", dienThoai: "(555) 558-2143", trangThai: "chobosung", phi: 4_200_000, daThu: true, ngayThu: "05/06", ngayNhan: "04/06", phuTrach: "David Park" },
79
- { ma: "CS-2026-0044", khach: "Robert Hayes", dienThoai: "(555) 471-8026", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "29/05", ngayNhan: "28/05", phuTrach: "Sarah Chen" },
80
- { ma: "CS-2026-0045", khach: "Karen Mitchell", dienThoai: "(555) 605-3317", trangThai: "dangxuly", phi: 3_100_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "David Park" },
81
- { ma: "CS-2026-0046", khach: "Daniel Brooks", dienThoai: "(555) 112-9084", trangThai: "chobosung", phi: 1_800_000, daThu: false, ngayNhan: "06/06", phuTrach: "Sarah Chen" },
82
- { ma: "CS-2026-0047", khach: "Crestline Foods", dienThoai: "(555) 446-2708", trangThai: "hoantat", phi: 5_600_000, daThu: true, ngayThu: "31/05", ngayNhan: "30/05", phuTrach: "David Park" },
83
- { ma: "CS-2026-0048", khach: "Laura Bennett", dienThoai: "(555) 833-5192", trangThai: "dangxuly", phi: 2_500_000, daThu: false, ngayNhan: "09/06", phuTrach: "James Walker" },
84
- { ma: "CS-2026-0049", khach: "Thomas Reed", dienThoai: "(555) 760-3844", trangThai: "hoantat", phi: 1_800_000, daThu: true, ngayThu: "02/06", ngayNhan: "27/05", phuTrach: "James Walker" },
85
- { ma: "CS-2026-0050", khach: "Kevin Doyle", dienThoai: "(555) 905-1273", trangThai: "dangxuly", phi: 4_200_000, daThu: true, ngayThu: "10/06", ngayNhan: "10/06", phuTrach: "James Walker" },
86
- { ma: "CS-2026-0051", khach: "Atlas Components", dienThoai: "(555) 271-6355", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "Maria Lopez" },
87
- { ma: "CS-2026-0052", khach: "Rachel Foster", dienThoai: "(555) 540-2866", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "04/06", ngayNhan: "03/06", phuTrach: "Sarah Chen" },
88
- { ma: "CS-2026-0053", khach: "Patrick Shaw", dienThoai: "(555) 119-4727", trangThai: "hoantat", phi: 1_200_000, daThu: true, ngayThu: "30/05", ngayNhan: "26/05", phuTrach: "David Park" },
89
- { ma: "CS-2026-0054", khach: "Summit Logistics", dienThoai: "(555) 662-8059", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "09/06", phuTrach: "Maria Lopez" },
90
- { ma: "CS-2026-0055", khach: "Brian Keller", dienThoai: "(555) 484-9131", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "07/06", phuTrach: "James Walker" },
91
- { ma: "CS-2026-0056", khach: "Emily Carter", dienThoai: "(555) 358-0274", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "29/05", phuTrach: "Sarah Chen" },
92
- { ma: "CS-2026-0057", khach: "Jason Pratt", dienThoai: "(555) 705-4693", trangThai: "dangxuly", phi: 1_200_000, daThu: true, ngayThu: "08/06", ngayNhan: "08/06", phuTrach: "David Park" },
93
- { ma: "CS-2026-0058", khach: "Beacon Supplies", dienThoai: "(555) 813-2460", trangThai: "hoantat", phi: 4_200_000, daThu: true, ngayThu: "01/06", ngayNhan: "28/05", phuTrach: "Maria Lopez" },
94
- { ma: "CS-2026-0059", khach: "Nina Alvarez", dienThoai: "(555) 270-1548", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "10/06", phuTrach: "Sarah Chen" },
95
- { ma: "CS-2026-0060", khach: "Steven Drake", dienThoai: "(555) 593-7186", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "09/06", ngayNhan: "08/06", phuTrach: "James Walker" },
96
- { ma: "CS-2026-0061", khach: "Harbor Industries", dienThoai: "(555) 047-5829", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Maria Lopez" },
97
- { ma: "CS-2026-0062", khach: "Grace Holloway", dienThoai: "(555) 826-3050", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "05/06", ngayNhan: "31/05", phuTrach: "David Park" },
98
- { ma: "CS-2026-0063", khach: "Olivia Manning", dienThoai: "(555) 631-2904", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "11/06", phuTrach: "Sarah Chen" },
319
+ { ma: "RC-2026-0041", khach: "Michael Torres", dienThoai: "(555) 384-7560", trangThai: "dangxuly", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "02/06", phuTrach: "Sarah Chen" },
320
+ { ma: "RC-2026-0042", khach: "Anna Whitfield", dienThoai: "(555) 220-4158", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "03/06", phuTrach: "Sarah Chen" },
321
+ { ma: "RC-2026-0043", khach: "Northwind Packaging", dienThoai: "(555) 558-2143", trangThai: "chobosung", phi: 4_200_000, daThu: true, ngayThu: "05/06", ngayNhan: "04/06", phuTrach: "David Park" },
322
+ { ma: "RC-2026-0044", khach: "Robert Hayes", dienThoai: "(555) 471-8026", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "29/05", ngayNhan: "28/05", phuTrach: "Sarah Chen" },
323
+ { ma: "RC-2026-0045", khach: "Karen Mitchell", dienThoai: "(555) 605-3317", trangThai: "dangxuly", phi: 3_100_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "David Park" },
324
+ { ma: "RC-2026-0046", khach: "Daniel Brooks", dienThoai: "(555) 112-9084", trangThai: "chobosung", phi: 1_800_000, daThu: false, ngayNhan: "06/06", phuTrach: "Sarah Chen" },
325
+ { ma: "RC-2026-0047", khach: "Crestline Foods", dienThoai: "(555) 446-2708", trangThai: "hoantat", phi: 5_600_000, daThu: true, ngayThu: "31/05", ngayNhan: "30/05", phuTrach: "David Park" },
326
+ { ma: "RC-2026-0048", khach: "Laura Bennett", dienThoai: "(555) 833-5192", trangThai: "dangxuly", phi: 2_500_000, daThu: false, ngayNhan: "09/06", phuTrach: "James Walker" },
327
+ { ma: "RC-2026-0049", khach: "Thomas Reed", dienThoai: "(555) 760-3844", trangThai: "hoantat", phi: 1_800_000, daThu: true, ngayThu: "02/06", ngayNhan: "27/05", phuTrach: "James Walker" },
328
+ { ma: "RC-2026-0050", khach: "Kevin Doyle", dienThoai: "(555) 905-1273", trangThai: "dangxuly", phi: 4_200_000, daThu: true, ngayThu: "10/06", ngayNhan: "10/06", phuTrach: "James Walker" },
329
+ { ma: "RC-2026-0051", khach: "Atlas Components", dienThoai: "(555) 271-6355", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "Maria Lopez" },
330
+ { ma: "RC-2026-0052", khach: "Rachel Foster", dienThoai: "(555) 540-2866", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "04/06", ngayNhan: "03/06", phuTrach: "Sarah Chen" },
331
+ { ma: "RC-2026-0053", khach: "Patrick Shaw", dienThoai: "(555) 119-4727", trangThai: "hoantat", phi: 1_200_000, daThu: true, ngayThu: "30/05", ngayNhan: "26/05", phuTrach: "David Park" },
332
+ { ma: "RC-2026-0054", khach: "Summit Logistics", dienThoai: "(555) 662-8059", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "09/06", phuTrach: "Maria Lopez" },
333
+ { ma: "RC-2026-0055", khach: "Brian Keller", dienThoai: "(555) 484-9131", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "07/06", phuTrach: "James Walker" },
334
+ { ma: "RC-2026-0056", khach: "Emily Carter", dienThoai: "(555) 358-0274", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "29/05", phuTrach: "Sarah Chen" },
335
+ { ma: "RC-2026-0057", khach: "Jason Pratt", dienThoai: "(555) 705-4693", trangThai: "dangxuly", phi: 1_200_000, daThu: true, ngayThu: "08/06", ngayNhan: "08/06", phuTrach: "David Park" },
336
+ { ma: "RC-2026-0058", khach: "Beacon Supplies", dienThoai: "(555) 813-2460", trangThai: "hoantat", phi: 4_200_000, daThu: true, ngayThu: "01/06", ngayNhan: "28/05", phuTrach: "Maria Lopez" },
337
+ { ma: "RC-2026-0059", khach: "Nina Alvarez", dienThoai: "(555) 270-1548", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "10/06", phuTrach: "Sarah Chen" },
338
+ { ma: "RC-2026-0060", khach: "Steven Drake", dienThoai: "(555) 593-7186", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "09/06", ngayNhan: "08/06", phuTrach: "James Walker" },
339
+ { ma: "RC-2026-0061", khach: "Harbor Industries", dienThoai: "(555) 047-5829", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Maria Lopez" },
340
+ { ma: "RC-2026-0062", khach: "Grace Holloway", dienThoai: "(555) 826-3050", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "05/06", ngayNhan: "31/05", phuTrach: "David Park" },
341
+ { ma: "RC-2026-0063", khach: "Olivia Manning", dienThoai: "(555) 631-2904", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "11/06", phuTrach: "Sarah Chen" },
99
342
  ];
100
343
 
101
344
  const TABS: { value: Tab; label: string }[] = [
@@ -113,43 +356,44 @@ const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label
113
356
  // Columns defined ONCE — Table renders the header from these and each
114
357
  // TableCell takes its width/align by position; the ⋯ is the trailing gutter.
115
358
  const COLUMNS: TableColumn[] = [
116
- { key: "ma", label: "Case ID", width: 122, sortable: true },
359
+ { key: "ma", label: "Record ID", width: 112, sortable: true },
117
360
  { key: "khach", label: "Customer", flex: 1, sortable: true },
118
- { key: "trangThai", label: "Status", width: 122, sortable: true },
119
- { key: "phi", label: "Fee", width: 138, align: "right", sortable: true },
120
- { key: "ngayNhan", label: "Received", width: 88, sortable: true },
361
+ { key: "trangThai", label: "Status", width: 112, sortable: true },
362
+ { key: "viec", label: "Tasks", width: 82 },
363
+ { key: "phi", label: "Fee", width: 116, align: "right", sortable: true },
364
+ { key: "ngayNhan", label: "Received", width: 72, sortable: true },
121
365
  ];
122
366
 
123
367
  // DOCUMENT — the paper the row needs NOW: while the fee is unpaid the
124
368
  // paper that matters is the payment slip; once paid, the stage's own document.
125
- function docFor(r: HoSo): { title: string; icon: IconName } {
126
- return r.daThu ? TRANG_THAI[r.trangThai].doc : { title: "Print payment slip", icon: "receipt" };
369
+ function docFor(r: HoSo, daThu: boolean): { title: string; icon: IconName } {
370
+ return daThu ? TRANG_THAI[r.trangThai].doc : { title: "Print payment slip", icon: "receipt" };
127
371
  }
128
372
 
129
373
  // The per-row Print button (the trailing action column) prints the stage's
130
374
  // document; the ⋯ menu carries the secondary actions — payment (disabled once
131
375
  // collected), edit, and the destructive cancel last.
132
- function menuFor(r: HoSo): ActionMenuItem[] {
376
+ function menuFor(r: HoSo, daThu: boolean): ActionMenuItem[] {
133
377
  return [
134
- { key: "thanh_toan", label: "Record payment", icon: "credit-card", disabled: r.daThu, onPress: () => {} },
135
- { key: "sua", label: "Edit case", icon: "pencil", onPress: () => {} },
136
- { key: "huy", label: "Cancel case", icon: "trash", danger: true, onPress: () => Alert.alert("Cancel this case?", "The case is closed and removed from the active register — it can't be undone.", [{ text: "Keep case", style: "cancel" }, { text: "Cancel case", style: "destructive", onPress: () => {} }]) },
378
+ { key: "thanh_toan", label: "Record payment", icon: "credit-card", disabled: daThu, onPress: () => {} },
379
+ { key: "sua", label: "Edit record", icon: "pencil", onPress: () => {} },
380
+ { key: "huy", label: "Cancel record", icon: "trash", danger: true, onPress: () => Alert.alert("Cancel this record?", "The record is closed and removed from the active register — it can't be undone.", [{ text: "Keep record", style: "cancel" }, { text: "Cancel record", style: "destructive", onPress: () => {} }]) },
137
381
  ];
138
382
  }
139
383
 
140
- function HoSoRow({ hs, selected, marked, selectable, onToggle, onPress }: { hs: HoSo; selected: boolean; marked: boolean; selectable: boolean; onToggle: (on: boolean) => void; onPress: () => void }) {
384
+ 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 }) {
141
385
  return (
142
386
  <TableRow
143
387
  onPress={onPress}
144
388
  selected={selected}
145
389
  marked={marked}
146
390
  minHeight={56}
147
- accessibilityLabel={`Open case ${hs.ma} — ${hs.khach}`}
391
+ accessibilityLabel={`Open record ${hs.ma} — ${hs.khach}`}
148
392
  leading={<CheckboxInput accessibilityLabel={selectable ? `Select ${hs.ma}` : `${hs.ma} — awaiting docs, not ready to export`} checked={marked} disabled={!selectable} onChange={onToggle} />}
149
393
  trailing={
150
394
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
151
- <Button title="Print" icon={docFor(hs).icon} color="secondary" onPress={() => {}} />
152
- <ActionMenu items={menuFor(hs)} accessibilityLabel={`Actions for ${hs.ma}`} />
395
+ <Button title="Print" icon={docFor(hs, daThu).icon} color="secondary" onPress={() => {}} />
396
+ <ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />
153
397
  </View>
154
398
  }
155
399
  >
@@ -159,17 +403,20 @@ function HoSoRow({ hs, selected, marked, selectable, onToggle, onPress }: { hs:
159
403
  <TableCell>
160
404
  <View style={{ gap: 2 }}>
161
405
  <Text size="sm" weight="medium" numberOfLines={1}>{hs.khach}</Text>
162
- <Text size="xs" color="muted" tabular>{hs.dienThoai}</Text>
406
+ <Text size="xs" color="muted" tabular numberOfLines={1}>{hs.dienThoai}</Text>
163
407
  </View>
164
408
  </TableCell>
165
409
  <TableCell>
166
410
  <Badge variant="dot" label={TRANG_THAI[hs.trangThai].label} color={TRANG_THAI[hs.trangThai].color} />
167
411
  </TableCell>
412
+ <TableCell>
413
+ <TasksPeek tasks={tasks} onChange={onTasksChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} />
414
+ </TableCell>
168
415
  <TableCell>
169
416
  <View style={{ gap: 2, alignItems: "flex-end" }}>
170
417
  <Text size="sm" tabular>{formatMoney(hs.phi)}</Text>
171
- <Text size="xs" color={hs.daThu ? "muted" : "danger"} tabular>
172
- {hs.daThu ? `Paid ${hs.ngayThu}` : "Unpaid"}
418
+ <Text size="xs" color={daThu ? "muted" : "danger"} tabular>
419
+ {daThu ? (hs.ngayThu ? `Paid ${hs.ngayThu}` : "Paid today") : "Unpaid"}
173
420
  </Text>
174
421
  </View>
175
422
  </TableCell>
@@ -180,11 +427,146 @@ function HoSoRow({ hs, selected, marked, selectable, onToggle, onPress }: { hs:
180
427
  );
181
428
  }
182
429
 
183
- // The case workspace behind a register row — the summary fields plus the
184
- // stage document and the Open case action. Keyed by id from the caller so
185
- // per-record state resets as steps the sequence.
186
- function HoSoWorkspace({ hs }: { hs: HoSo }) {
187
- const doc = docFor(hs);
430
+ // The case workspace behind a register row — the drawer mirrors a REAL record
431
+ // workspace: inline-editable fields (the zinc-50 chips; `InlineStatic` for the
432
+ // system-owned ones) and the record's CHARGES SUMMARY (grouped fee rows with
433
+ // the status as a quiet suffix divider → ONE emphasized closing number — the
434
+ // same shape at popover density). Keyed by id from the caller so per-record
435
+ // state resets as ◀ ▶ steps the sequence.
436
+ 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 }) {
437
+ const doc = docFor(hs, daThu);
438
+ const { navigate } = useScreenRouter();
439
+ // A record CREATED this session (not in the seed set) opens with only what
440
+ // creation gave it — no fabricated June history; seeded records carry the
441
+ // full worked mock.
442
+ const isSeed = HO_SO.some((r) => r.ma === hs.ma);
443
+ // Linked records — in a real app, records related via a link field; mocked
444
+ // as two sibling rows here (their own customers show on each row).
445
+ const linked = isSeed ? HO_SO.filter((r) => r.ma !== hs.ma).slice(0, 2) : [];
446
+ const [khach, setKhach] = useState(hs.khach);
447
+ const [dienThoai, setDienThoai] = useState(hs.dienThoai);
448
+ const [assignee, setAssignee] = useState<string | null>(memberIdOf(hs.phuTrach));
449
+ const [ngayNhan, setNgayNhan] = useState(() => isoReceived(hs.ngayNhan));
450
+ const [due, setDue] = useState("");
451
+ const [priority, setPriority] = useState(hs.trangThai === "chobosung" ? "urgent" : "standard");
452
+ const [declared, setDeclared] = useState<number | null>(isSeed ? 25_000_000 : null);
453
+ const [tags, setTags] = useState<string[]>(isSeed ? (hs.trangThai === "hoantat" ? ["corporate"] : ["corporate", "courier"]) : []);
454
+ const [files, setFiles] = useState<DisplayFile[]>(isSeed ? [sampleDoc("id-scan", "#3b82f6"), sampleDoc("application-form", "#10b981")] : []);
455
+ // ── the case's money: CHARGES owed vs RECEIPTS received — outstanding is
456
+ // the difference. Recording a payment appends a receipt; everything
457
+ // (bar, group sums, the closing number) recomputes from the two lists.
458
+ const charges = isSeed
459
+ ? [
460
+ { id: "f1", item: "Service fee", amount: hs.phi, basis: "Fixed tariff · type A", chargedOn: "03/06", by: "Sarah Chen", ref: "" },
461
+ { id: "f2", item: "Notarization", amount: 350_000, basis: "Per notarized copy × 2", chargedOn: "04/06", by: "David Park", ref: "" },
462
+ { id: "f3", item: "Government levy", amount: 1_200_000, basis: "State fee schedule 2026", chargedOn: "04/06", by: "System", ref: "LEVY-2026-118" },
463
+ ]
464
+ : hs.phi > 0
465
+ ? [{ id: "f1", item: "Service fee", amount: hs.phi, basis: "Fixed tariff · type A", chargedOn: "Today", by: "Sarah Chen", ref: "" }]
466
+ : [];
467
+ const [receipts, setReceipts] = useState(() =>
468
+ isSeed
469
+ ? [
470
+ { id: "r1", date: hs.ngayNhan, method: "Cash", note: "Disbursements", amount: 1_550_000 },
471
+ ...(daThu ? [{ id: "r2", date: hs.ngayThu ?? "Today", method: "Bank transfer", note: "Service fee", amount: hs.phi }] : []),
472
+ ]
473
+ : [],
474
+ );
475
+ const totalCharged = charges.reduce((t, c) => t + c.amount, 0);
476
+ const received = receipts.reduce((t, r) => t + r.amount, 0);
477
+ const outstanding = Math.max(0, totalCharged - received);
478
+ // the record-payment stage gate (1–2 fields → a popover, per the gate law)
479
+ const [payOpen, setPayOpen] = useState(false);
480
+ const [payAmount, setPayAmount] = useState<number | null>(null);
481
+ const [payMethod, setPayMethod] = useState("transfer");
482
+ const PAY_METHODS: PickerOption[] = [
483
+ { value: "cash", label: "Cash" },
484
+ { value: "transfer", label: "Bank transfer" },
485
+ { value: "card", label: "Card" },
486
+ ];
487
+ const recordPayment = () => {
488
+ if (!payAmount || payAmount <= 0) return;
489
+ setReceipts((prev) => [
490
+ ...prev,
491
+ { id: `r_${prev.length + 1}`, date: "Today", method: PAY_METHODS.find((m) => m.value === payMethod)?.label ?? payMethod, note: "Payment", amount: payAmount },
492
+ ]);
493
+ // Settling the ledger flips the record's paid state at the PAGE level —
494
+ // the register row (Unpaid tag, Print, menu) updates live.
495
+ if (totalCharged - (received + payAmount) <= 0) onPaid();
496
+ setPayOpen(false);
497
+ };
498
+ const remainingTasks = tasks.filter((t) => !t.done).length;
499
+ // ── the handoff dialog — never a blocked CTA: pressing Hand off opens the
500
+ // dialog (receiving assignee + a note for the next desk); incomplete tasks
501
+ // WARN inside, they don't block.
502
+ const [handoffOpen, setHandoffOpen] = useState(false);
503
+ const [handoffAssignee, setHandoffAssignee] = useState<string | null>("m2");
504
+ const [handoffNote, setHandoffNote] = useState("");
505
+
506
+ // ── activity — the interaction log (the telesales call-log shape): every
507
+ // entry is an OUTCOME; a call-back asks for its follow-up date; the composer
508
+ // sits ABOVE the history, always visible.
509
+ // Attachments ride the log entry: picked in the composer (removable
510
+ // thumbnails), then shown as an expandable thumbnail grid ON the entry —
511
+ // pressing one opens the shared gallery.
512
+ const [attached, setAttached] = useState<DisplayFile[]>([]);
513
+ const [galleryFiles, setGalleryFiles] = useState<DisplayFile[]>([]);
514
+ const [galleryIdx, setGalleryIdx] = useState<number | null>(null);
515
+ const attachmentDetails = (docs: DisplayFile[]) => (
516
+ <FileThumbnailGrid
517
+ files={docs}
518
+ itemSize={64}
519
+ onFilePress={(f) => {
520
+ setGalleryFiles(docs);
521
+ setGalleryIdx(docs.findIndex((x) => x.id === f.id));
522
+ }}
523
+ />
524
+ );
525
+ const [logOutcome, setLogOutcome] = useState<Outcome | "">("");
526
+ const [logNote, setLogNote] = useState("");
527
+ const [followUp, setFollowUp] = useState<string | null>(null);
528
+ const [events, setEvents] = useState<TimelineItem[]>(() => isSeed ? [
529
+ { 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> },
530
+ { id: "e2", icon: "credit-card", iconColor: colors.emerald[500], label: "Payment received — 1.550.000 ₫", description: "Cash · disbursements advance", right: <Text size="xs" color="muted">03/06</Text> },
531
+ { id: "e3", icon: "phone", iconColor: colors.zinc[400], label: "Call — no answer", right: <Text size="xs" color="muted">03/06</Text> },
532
+ { id: "e4", icon: "file-text", iconColor: colors.zinc[500], label: "Receipt printed", right: <Text size="xs" color="muted">03/06</Text> },
533
+ { 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")]) },
534
+ { id: "e6", icon: "plus", iconColor: colors.blue[500], label: "Record created", right: <Text size="xs" color="muted">02/06</Text> },
535
+ ] : [
536
+ { id: "e1", icon: "plus", iconColor: colors.blue[500], label: "Record created", right: <Text size="xs" color="muted">Today</Text> },
537
+ ]);
538
+ // The NOTE is the record — the one required thing. The outcome pill is an
539
+ // optional tag (tap the active pill to deselect); follow-up an optional date.
540
+ const canLog = logNote.trim() !== "";
541
+ const logEvent = () => {
542
+ if (!canLog) return;
543
+ const meta = logOutcome === "" ? null : OUTCOME_META[logOutcome];
544
+ const note = logNote.trim();
545
+ const docs = attached;
546
+ // With an outcome the note goes in the description; without one the note IS
547
+ // the label — either way follow-up and file count always survive.
548
+ const parts = [
549
+ meta ? note : null,
550
+ followUp ? `follow up ${followUp}` : null,
551
+ docs.length > 0 ? `${docs.length} ${docs.length === 1 ? "file" : "files"}` : null,
552
+ ].filter((x): x is string => x !== null);
553
+ setEvents((prev) => [
554
+ {
555
+ id: `e_${prev.length + 1}`,
556
+ icon: meta ? meta.icon : "message-square",
557
+ iconColor: meta ? meta.color : colors.blue[500],
558
+ label: meta ? meta.label : note,
559
+ description: parts.join(" · ") || undefined,
560
+ right: <Text size="xs" color="muted">Today</Text>,
561
+ details: docs.length > 0 ? attachmentDetails(docs) : undefined,
562
+ },
563
+ ...prev,
564
+ ]);
565
+ setLogNote("");
566
+ setFollowUp(null);
567
+ setLogOutcome("");
568
+ setAttached([]);
569
+ };
188
570
  return (
189
571
  <>
190
572
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
@@ -196,63 +578,519 @@ function HoSoWorkspace({ hs }: { hs: HoSo }) {
196
578
  <Text size="sm" color="muted">{`Received ${hs.ngayNhan} · ${hs.phuTrach}`}</Text>
197
579
  </View>
198
580
  <Divider />
199
- <View style={{ gap: 8 }}>
200
- <DetailRow label="Customer" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{hs.khach}</Text></DetailRow>
201
- <DetailRow label="Phone" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{hs.dienThoai}</Text></DetailRow>
202
- <DetailRow label="Assignee" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{hs.phuTrach}</Text></DetailRow>
203
- <DetailRow label="Case fee" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{formatMoney(hs.phi)}</Text></DetailRow>
204
- <DetailRow label="Payment" labelWidth={120} minHeight={24}><Text size="sm" color={!hs.daThu ? "danger" : "default"} tabular>{hs.daThu ? `Paid ${hs.ngayThu}` : "Unpaid"}</Text></DetailRow>
205
- </View>
581
+ {/* DETAILS every field type in the inline vocabulary, on ONE
582
+ DetailTable grid: text · member · dates (plain + optional-time) ·
583
+ select-with-dots · money number · a multi-value TAG field · a
584
+ system-owned static. The chips mark what's editable. */}
585
+ <Section>
586
+ <SectionHeading>
587
+ <SectionHeadingTitle>Details</SectionHeadingTitle>
588
+ </SectionHeading>
589
+ <DetailTable labelWidth={120} trailingWidth={76}>
590
+ <DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
591
+ <DetailRow label="Phone" trailing={<Button title="Call" color="secondary" icon="phone" onPress={() => {}} />}>
592
+ <InlineTextInput value={dienThoai} onSave={persist(setDienThoai)} accessibilityLabel="Phone" />
593
+ </DetailRow>
594
+ <DetailRow label="Assignee">
595
+ <InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
596
+ </DetailRow>
597
+ <DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
598
+ <DetailRow label="Due" trailing={<Button title="Today" color="secondary" onPress={() => void persist(setDue)(toISODate(new Date()))} />}>
599
+ <InlineDatePicker value={due} optionalTime onSave={persist(setDue)} placeholder="Set due date…" accessibilityLabel="Due date" />
600
+ </DetailRow>
601
+ <DetailRow label="Priority">
602
+ <InlineSelect
603
+ value={priority}
604
+ onSave={persist(setPriority)}
605
+ options={PRIORITY_OPTS}
606
+ accessibilityLabel="Priority"
607
+ renderOptionContent={(o) => (
608
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
609
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PRIORITY_DOT[o.value] }} />
610
+ <Text size="sm">{o.label}</Text>
611
+ </View>
612
+ )}
613
+ />
614
+ </DetailRow>
615
+ <DetailRow label="Declared value">
616
+ <InlineNumberInput value={declared} onSave={persist(setDeclared)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Declared value" />
617
+ </DetailRow>
618
+ <DetailRow label="Tags">
619
+ <InlineTagSelect
620
+ value={tags}
621
+ onSave={persist(setTags)}
622
+ options={TAG_OPTIONS}
623
+ renderTag={renderTagBadge}
624
+ placeholder="Add tags…"
625
+ accessibilityLabel="Tags"
626
+ />
627
+ </DetailRow>
628
+ <DetailRow label="Record ID" trailing={<Badge label="System" color="zinc" />}>
629
+ <InlineStatic value={hs.ma} tabular />
630
+ </DetailRow>
631
+ </DetailTable>
632
+ </Section>
633
+ <Divider />
634
+ {/* TASKS — the Record template's section shape: heading + the compact
635
+ meter, the shared columnar checklist below (capture at the end);
636
+ the same state feeds the register column + its peek. */}
637
+ <Section>
638
+ <SectionHeading>
639
+ <SectionHeadingTitle>Tasks</SectionHeadingTitle>
640
+ {tasks.length > 0 ? (
641
+ <View style={{ width: 120 }}>
642
+ <ProgressBar compact value={tasks.filter((t) => t.done).length} max={tasks.length} format="fraction" color={colors.zinc[500]} completeColor={colors.emerald[500]} />
643
+ </View>
644
+ ) : null}
645
+ </SectionHeading>
646
+ <TasksChecklist tasks={tasks} onChange={onTasksChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} />
647
+ </Section>
648
+ <Divider />
649
+ {/* LINKED RECORDS — a record surfaces its relatives (the customer's
650
+ other cases); pressing one PUSHES its screen inside this drawer
651
+ (ScreenRouter), back returns here with scroll preserved. Hidden
652
+ while the record has none (a fresh create). */}
653
+ {linked.length > 0 ? (
654
+ <Section>
655
+ <SectionHeading>
656
+ <SectionHeadingTitle>Linked records</SectionHeadingTitle>
657
+ <SectionHeadingMeta>{`${linked.length} linked`}</SectionHeadingMeta>
658
+ </SectionHeading>
659
+ <View>
660
+ {linked.map((r) => (
661
+ <ListItem
662
+ key={r.ma}
663
+ title={r.ma}
664
+ description={r.khach}
665
+ right={
666
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
667
+ <Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
668
+ <Text size="sm" tabular color="muted">{formatMoney(r.phi)}</Text>
669
+ <Icon name="chevron-right" size={16} color={colors.zinc[400]} />
670
+ </View>
671
+ }
672
+ onPress={() => navigate(`/record/${r.ma}`)}
673
+ />
674
+ ))}
675
+ </View>
676
+ </Section>
677
+ ) : null}
678
+ <Divider />
679
+ {/* FILES — the document CRUD: FileRows (press → gallery preview, ⋯ →
680
+ remove) + the section's OWN CTA; an EXPECTED document renders as a
681
+ ghost placeholder row with its own chase action. */}
682
+ <Section>
683
+ <SectionHeading>
684
+ <SectionHeadingTitle>Files</SectionHeadingTitle>
685
+ <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta>
686
+ <Button
687
+ title="Add file"
688
+ color="secondary"
689
+ icon="plus"
690
+ onPress={() => {
691
+ void pickFiles({ multiple: true }).then((picked) => {
692
+ if (picked.length > 0) setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
693
+ });
694
+ }}
695
+ />
696
+ </SectionHeading>
697
+ <FileRows files={files} onRemove={(f) => setFiles((prev) => prev.filter((x) => x.id !== f.id))} />
698
+ <FileRow
699
+ name="Signed service contract"
700
+ placeholder
701
+ meta="Expected — not provided yet"
702
+ trailing={<Button title="Request" color="muted" onPress={() => {}} />}
703
+ />
704
+ </Section>
705
+ <Divider />
706
+ {/* the PAYMENT summary — a plain financial STATEMENT: the two sides
707
+ of the ledger (charges owed · receipts received), every amount on
708
+ ONE right-aligned tabular column, a divider-set emphasized closing
709
+ number, and the record-payment popover that appends a receipt.
710
+ No bars, no charts — the numbers are the interface. */}
711
+ {charges.length > 0 ? (
712
+ <Section>
713
+ <SectionHeading>
714
+ <SectionHeadingTitle>Payment</SectionHeadingTitle>
715
+ <SectionHeadingMeta>{`${receipts.length} ${receipts.length === 1 ? "receipt" : "receipts"}`}</SectionHeadingMeta>
716
+ </SectionHeading>
717
+ {/* the Ledger compound: groups with their sums, peekable fee rows
718
+ (press → the fee's particulars in an anchored popover; references
719
+ live INSIDE the peek), receipts with meta, one emphasized close */}
720
+ <Ledger formatValue={formatMoney}>
721
+ <LedgerGroup label="Charges" total={totalCharged}>
722
+ {charges.map((c) => (
723
+ <LedgerRow
724
+ key={c.id}
725
+ label={c.item}
726
+ value={c.amount}
727
+ peek={
728
+ <View style={{ gap: 8 }}>
729
+ <View style={{ gap: 2 }}>
730
+ <Text size="sm" weight="semibold">{c.item}</Text>
731
+ <Text size="xs" color="muted">{`Charged ${c.chargedOn} · by ${c.by}`}</Text>
732
+ </View>
733
+ <DetailRow label="Basis" labelSize="xs" labelWidth={72}><Text size="xs">{c.basis}</Text></DetailRow>
734
+ <DetailRow label="Amount" labelSize="xs" labelWidth={72}><Text size="xs" tabular>{formatMoney(c.amount)}</Text></DetailRow>
735
+ {c.ref ? (
736
+ <DetailRow label="Reference" labelSize="xs" labelWidth={72}>
737
+ <Link size="xs" onPress={() => {}} accessibilityLabel={c.ref}>{c.ref}</Link>
738
+ </DetailRow>
739
+ ) : null}
740
+ </View>
741
+ }
742
+ />
743
+ ))}
744
+ </LedgerGroup>
745
+ <LedgerGroup label="Received" total={received > 0 ? -received : undefined}>
746
+ {receipts.map((r) => (
747
+ <LedgerRow key={r.id} label={r.note} meta={`${r.date} · ${r.method}`} value={-r.amount} tone="success" />
748
+ ))}
749
+ </LedgerGroup>
750
+ <LedgerTotal label="Outstanding" value={outstanding} tone="danger" zeroLabel="Paid in full" />
751
+ </Ledger>
752
+ {outstanding > 0 ? (
753
+ <View style={{ flexDirection: "row", justifyContent: "flex-end" }}>
754
+ <Popover
755
+ side="top"
756
+ align="end"
757
+ open={payOpen}
758
+ onOpenChange={(o) => {
759
+ setPayOpen(o);
760
+ if (o) {
761
+ setPayAmount(outstanding);
762
+ setPayMethod("transfer");
763
+ }
764
+ }}
765
+ >
766
+ <PopoverTrigger>
767
+ <Button title="Record payment" color="secondary" />
768
+ </PopoverTrigger>
769
+ <PopoverContent style={{ width: 300 }}>
770
+ <View style={{ gap: 12 }}>
771
+ <View style={{ gap: 2 }}>
772
+ <Text size="sm" weight="semibold">Record a payment</Text>
773
+ <Text size="xs" color="muted">Prefilled with the outstanding balance.</Text>
774
+ </View>
775
+ <FormField label="Amount">
776
+ <NumberInput value={payAmount} onValueChange={(v) => setPayAmount(v ?? null)} min={0} accessibilityLabel="Payment amount" />
777
+ </FormField>
778
+ <FormField label="Method">
779
+ <Picker options={PAY_METHODS} value={payMethod} onValueChange={setPayMethod} accessibilityLabel="Payment method" />
780
+ </FormField>
781
+ <PopoverFooter>
782
+ <Button title="Record" color="primary" disabled={!payAmount || payAmount <= 0} onPress={recordPayment} />
783
+ </PopoverFooter>
784
+ </View>
785
+ </PopoverContent>
786
+ </Popover>
787
+ </View>
788
+ ) : null}
789
+ </Section>
790
+ ) : null}
791
+ <Divider />
792
+ {/* ACTIVITY — the interaction composer (the CRM touch-log shape): the
793
+ NOTE is the record and the one requirement; the outcome is an
794
+ optional PILL (tap the active one to deselect); Follow up an
795
+ always-visible optional date; the primary Log says WHY when
796
+ disabled. The labelled history sits below, newest first. */}
797
+ <Section>
798
+ <SectionHeading>
799
+ <SectionHeadingTitle>Activity</SectionHeadingTitle>
800
+ <SectionHeadingMeta>{`${events.length} events`}</SectionHeadingMeta>
801
+ </SectionHeading>
802
+ <View style={{ gap: 10 }}>
803
+ <Text size="sm" weight="semibold">Log an interaction</Text>
804
+ <ChipGroup
805
+ accessibilityLabel="Outcome"
806
+ options={OUTCOME_OPTS}
807
+ value={logOutcome}
808
+ onValueChange={(v) => setLogOutcome(v === logOutcome ? "" : v)}
809
+ />
810
+ <TextInputField
811
+ value={logNote}
812
+ onChangeText={setLogNote}
813
+ multiline
814
+ numberOfLines={2}
815
+ autoGrow
816
+ placeholder="What was said?"
817
+ accessibilityLabel="Interaction note"
818
+ />
819
+ {attached.length > 0 ? (
820
+ <FileThumbnailGrid
821
+ files={attached}
822
+ itemSize={64}
823
+ onRemove={(id) => setAttached((prev) => prev.filter((x) => x.id !== id))}
824
+ />
825
+ ) : null}
826
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
827
+ <Text size="sm" color="muted">Follow up</Text>
828
+ <View style={{ flex: 1 }}>
829
+ <InlineDatePicker value={followUp} onSave={setFollowUp} placeholder="Optional" accessibilityLabel="Follow-up date" />
830
+ </View>
831
+ </View>
832
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
833
+ <Button
834
+ title="Attach files"
835
+ icon="paperclip"
836
+ color="secondary"
837
+ onPress={() => {
838
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((picked) => {
839
+ if (picked.length > 0) setAttached((prev) => [...prev, ...toDisplayFiles(picked)]);
840
+ });
841
+ }}
842
+ />
843
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexShrink: 1 }}>
844
+ {!canLog ? <Text size="xs" color="muted">Write what was said to log it</Text> : null}
845
+ <Button title="Log" color="primary" disabled={!canLog} onPress={logEvent} />
846
+ </View>
847
+ </View>
848
+ </View>
849
+ <View style={{ gap: 6 }}>
850
+ <Text size="xs" weight="medium" color="muted">History</Text>
851
+ <Timeline items={events} />
852
+ </View>
853
+ </Section>
854
+ <Divider />
855
+ {/* the destructive operation sits LAST, apart from the work — the same
856
+ DangerZone the Record template closes with */}
857
+ <DangerZone
858
+ title="Delete record"
859
+ description="Permanently remove this record, its charges, receipts, and files. This can't be undone."
860
+ >
861
+ <Button title="Delete record" color="danger" onPress={() => {}} />
862
+ </DangerZone>
206
863
  </ScrollView>
864
+ <FileGalleryModal files={galleryFiles} activeIndex={galleryIdx} onIndexChange={setGalleryIdx} />
207
865
  {/* pinned footer — the stage's paper + the way into the full record */}
866
+ {/* the HANDOFF — never a blocked CTA: pressing it opens the dialog
867
+ (assignee + a note for the receiving desk); open tasks WARN there */}
208
868
  <DrawerFooter>
209
- <Text size="xs" color="muted" style={{ flex: 1 }}>
210
- {hs.daThu ? "Fee collected — print the current stage's document." : "Fee unpaid print the payment slip before proceeding."}
211
- </Text>
869
+ {remainingTasks > 0 ? (
870
+ <Text size="xs" color="muted" style={{ flex: 1 }}>
871
+ {`${remainingTasks} ${remainingTasks === 1 ? "open task" : "open tasks"}`}
872
+ </Text>
873
+ ) : (
874
+ <View style={{ flex: 1 }} />
875
+ )}
212
876
  <Button title={doc.title} icon={doc.icon} color="secondary" onPress={() => {}} />
213
- <Button title="Open case" color="primary" onPress={() => {}} />
877
+ <Button title="Hand off to Operations" color="primary" onPress={() => setHandoffOpen(true)} />
878
+ </DrawerFooter>
879
+ {/* the handoff dialog — initial info for the receiving desk; confirming
880
+ moves the record off this desk (the drawer closes: it left your
881
+ register) */}
882
+ <Dialog width={480} open={handoffOpen} onOpenChange={setHandoffOpen}>
883
+ <DialogHeader>
884
+ <DialogHeaderTitle>Hand off to Operations</DialogHeaderTitle>
885
+ </DialogHeader>
886
+ <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
887
+ {remainingTasks > 0 ? (
888
+ <Callout tone="warning">
889
+ <CalloutText>
890
+ {`${remainingTasks} ${remainingTasks === 1 ? "task is" : "tasks are"} still open — they carry over to Operations.`}
891
+ </CalloutText>
892
+ </Callout>
893
+ ) : null}
894
+ <FormField label="Assign to">
895
+ <MemberSelect members={MEMBERS} value={handoffAssignee} onValueChange={setHandoffAssignee} accessibilityLabel="Receiving assignee" />
896
+ </FormField>
897
+ <FormField label="Note for Operations" optional optionalLabel="Optional">
898
+ <TextInputField
899
+ value={handoffNote}
900
+ onChangeText={setHandoffNote}
901
+ multiline
902
+ numberOfLines={2}
903
+ autoGrow
904
+ placeholder="Anything the next desk should know…"
905
+ accessibilityLabel="Handoff note"
906
+ />
907
+ </FormField>
908
+ </View>
909
+ <DialogFooter>
910
+ <Button title="Cancel" color="secondary" onPress={() => setHandoffOpen(false)} />
911
+ <Button
912
+ title="Hand off"
913
+ color="primary"
914
+ disabled={handoffAssignee === null}
915
+ onPress={() => {
916
+ setHandoffOpen(false);
917
+ onClose();
918
+ }}
919
+ />
920
+ </DialogFooter>
921
+ </Dialog>
922
+ </>
923
+ );
924
+ }
925
+
926
+ /** The drawer shell that READS the navigation stack: at the root it titles the
927
+ * customer and offers the ◀ ▶ sequence; on a pushed screen the header swaps
928
+ * to a BACK button + the pushed case id (the proper home for back — drawer
929
+ * chrome, not scroll content), and the sequence hides (stepping the root
930
+ * record from inside a linked one would be disorienting). */
931
+ function RecordDrawer({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, onDismissSuggestion, onClose, onPrev, onNext, position }: {
932
+ hs: HoSo;
933
+ daThu: boolean;
934
+ onPaid: () => void;
935
+ tasks: RecTask[];
936
+ onTasksChange: (next: RecTask[]) => void;
937
+ suggestions: string[];
938
+ onDismissSuggestion: (label: string) => void;
939
+ onClose: () => void;
940
+ onPrev?: () => void;
941
+ onNext?: () => void;
942
+ position?: string;
943
+ }) {
944
+ const { canGoBack, goBack, params } = useScreenRouter();
945
+ return (
946
+ <Drawer
947
+ open
948
+ onOpenChange={(o) => !o && onClose()}
949
+ title={
950
+ canGoBack ? (
951
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
952
+ <IconButton icon="arrow-left" accessibilityLabel="Back" onPress={goBack} />
953
+ <Text size="md" weight="semibold" tabular>{params.ma ?? ""}</Text>
954
+ </View>
955
+ ) : (
956
+ hs.khach
957
+ )
958
+ }
959
+ width={440}
960
+ onPrev={canGoBack ? undefined : onPrev}
961
+ onNext={canGoBack ? undefined : onNext}
962
+ position={canGoBack ? undefined : position}
963
+ >
964
+ <Screen route="">
965
+ <HoSoWorkspace hs={hs} daThu={daThu} onPaid={onPaid} tasks={tasks} onTasksChange={onTasksChange} suggestions={suggestions} onDismissSuggestion={onDismissSuggestion} onClose={onClose} />
966
+ </Screen>
967
+ <Screen route="/record/:ma">{(p) => <LinkedRecordScreen ma={p.ma} />}</Screen>
968
+ </Drawer>
969
+ );
970
+ }
971
+
972
+ /** The PUSHED linked-record screen — a REAL editable workspace, not a peek:
973
+ * the drawer HEADER carries the back button (the proper place — see
974
+ * RecordDrawer), the fields edit in place with their own state, and the footer
975
+ * carries the pushed record's OWN CTAs. */
976
+ function LinkedRecordScreen({ ma }: { ma: string }) {
977
+ const r = HO_SO.find((x) => x.ma === ma);
978
+ const [khach, setKhach] = useState(r?.khach ?? "");
979
+ const [dienThoai, setDienThoai] = useState(r?.dienThoai ?? "");
980
+ const [assignee, setAssignee] = useState<string | null>(memberIdOf(r?.phuTrach ?? ""));
981
+ const [ngayNhan, setNgayNhan] = useState(() => (r ? isoReceived(r.ngayNhan) : ""));
982
+ const [collected, setCollected] = useState(r?.daThu ? "yes" : "no");
983
+ const [fee, setFee] = useState<number | null>(r?.phi ?? null);
984
+ if (!r) return null;
985
+ return (
986
+ <>
987
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
988
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
989
+ <Badge label={TRANG_THAI[r.trangThai].label} color={TRANG_THAI[r.trangThai].color} />
990
+ <Text size="sm" color="muted">{`Linked record · ${r.khach}`}</Text>
991
+ </View>
992
+ <DetailTable labelWidth={120}>
993
+ <DetailRow label="Customer"><InlineTextInput value={khach} onSave={persist(setKhach)} accessibilityLabel="Customer" /></DetailRow>
994
+ <DetailRow label="Phone"><InlineTextInput value={dienThoai} onSave={persist(setDienThoai)} accessibilityLabel="Phone" /></DetailRow>
995
+ <DetailRow label="Assignee">
996
+ <InlineMemberSelect members={MEMBERS} value={assignee} onSave={persist(setAssignee)} placeholder="Assign…" accessibilityLabel="Assignee" />
997
+ </DetailRow>
998
+ <DetailRow label="Received"><InlineDatePicker value={ngayNhan} onSave={persist(setNgayNhan)} accessibilityLabel="Received date" /></DetailRow>
999
+ <DetailRow label="Fee">
1000
+ <InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
1001
+ </DetailRow>
1002
+ <DetailRow label="Collected">
1003
+ <InlineSelect
1004
+ value={collected}
1005
+ onSave={persist(setCollected)}
1006
+ options={[{ value: "yes", label: "Collected" }, { value: "no", label: "Outstanding" }]}
1007
+ accessibilityLabel="Collected"
1008
+ renderOptionContent={(o) => (
1009
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1010
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: o.value === "yes" ? colors.emerald[500] : colors.amber[500] }} />
1011
+ <Text size="sm">{o.label}</Text>
1012
+ </View>
1013
+ )}
1014
+ />
1015
+ </DetailRow>
1016
+ </DetailTable>
1017
+ </ScrollView>
1018
+ {/* the pushed record's OWN footer — different CTAs from the root case */}
1019
+ <DrawerFooter>
1020
+ <Text size="xs" color="muted" style={{ flex: 1 }}>Changes save in place on this linked record.</Text>
1021
+ <Button title="Print receipt" icon="file-text" color="secondary" onPress={() => {}} />
1022
+ <Button title="Open full record" color="primary" onPress={() => {}} />
214
1023
  </DrawerFooter>
215
1024
  </>
216
1025
  );
217
1026
  }
218
1027
 
219
- /** New case is a 2-field gate — a popover form anchored to the primary
220
- * button (Dialog would be overweight for customer + fee). */
221
- function TaoHoSoForm() {
1028
+ /** New record is a THREE-field gate — past the popover threshold (1–2
1029
+ * fields), so it's a Dialog. NOT a wizard: create-then-refine means the
1030
+ * gate stays minimal and the record's own workspace (the drawer it opens
1031
+ * into) is where everything else is refined. */
1032
+ function CreateRecordDialog({ onCreate }: { onCreate: (khach: string, dienThoai: string, phi: number) => void }) {
1033
+ const [open, setOpen] = useState(false);
222
1034
  const [khach, setKhach] = useState("");
1035
+ const [dienThoai, setDienThoai] = useState("");
223
1036
  const [phi, setPhi] = useState<number | null>(null);
1037
+ const create = () => {
1038
+ if (khach.trim() === "") return;
1039
+ onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
1040
+ setOpen(false);
1041
+ setKhach("");
1042
+ setDienThoai("");
1043
+ setPhi(null);
1044
+ };
224
1045
  return (
225
- <Popover side="bottom" align="end">
226
- <PopoverTrigger>
227
- <Button title="New case" color="primary" />
228
- </PopoverTrigger>
229
- <PopoverContent style={{ width: 320 }} disableBodyScroll>
230
- <View style={{ gap: 12 }}>
231
- <View style={{ gap: 2 }}>
232
- <Text size="sm" weight="semibold">Create a new case</Text>
233
- <Text size="xs" color="muted">The case starts in Processing — print the receipt right after creating it.</Text>
234
- </View>
1046
+ <>
1047
+ <Button title="New record" color="primary" onPress={() => setOpen(true)} />
1048
+ <Dialog width={440} open={open} onOpenChange={setOpen}>
1049
+ <DialogHeader>
1050
+ <DialogHeaderTitle>New record</DialogHeaderTitle>
1051
+ </DialogHeader>
1052
+ <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
1053
+ <Text size="xs" color="muted">The record starts in Processing and opens for refinement — details, tasks, and payment live on the record itself.</Text>
235
1054
  <FormField label="Customer">
236
- <Combobox
237
- options={CUSTOMER_OPTIONS}
238
- onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
239
- allowCustom
240
- customOptionPlacement="top"
241
- customOptionLabel={(q) => `Create new customer “${q}”`}
242
- >
243
- <ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
244
- <ComboboxContent />
245
- </Combobox>
1055
+ {khach === "" ? (
1056
+ /* find-or-create: the input stays a pure search (reflectSelection
1057
+ off a custom "create" pick has no option to reflect anyway);
1058
+ the pick renders BELOW as the attached line with Change */
1059
+ <Combobox
1060
+ options={CUSTOMER_OPTIONS}
1061
+ onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
1062
+ reflectSelection={false}
1063
+ allowCustom
1064
+ customOptionPlacement="top"
1065
+ customOptionLabel={(q) => `Create new customer “${q}”`}
1066
+ >
1067
+ <ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
1068
+ <ComboboxContent />
1069
+ </Combobox>
1070
+ ) : (
1071
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40 }}>
1072
+ <Icon name={CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "building-2" : "plus"} size={16} color={colors.zinc[400]} />
1073
+ <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
1074
+ {khach}
1075
+ {CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " · new customer"}
1076
+ </Text>
1077
+ <Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
1078
+ </View>
1079
+ )}
1080
+ </FormField>
1081
+ <FormField label="Phone" optional optionalLabel="Optional">
1082
+ <TextInputField value={dienThoai} onChangeText={setDienThoai} placeholder="(555) 000-0000" accessibilityLabel="Phone" />
246
1083
  </FormField>
247
- <FormField label="Case fee">
248
- <NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Case fee" />
1084
+ <FormField label="Service fee">
1085
+ <NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Service fee" />
249
1086
  </FormField>
250
1087
  </View>
251
- <PopoverFooter>
252
- <Button title="Create case" color="primary" disabled={khach === ""} onPress={() => {}} />
253
- </PopoverFooter>
254
- </PopoverContent>
255
- </Popover>
1088
+ <DialogFooter>
1089
+ <Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
1090
+ <Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={create} />
1091
+ </DialogFooter>
1092
+ </Dialog>
1093
+ </>
256
1094
  );
257
1095
  }
258
1096
 
@@ -261,6 +1099,51 @@ export function TplItemList() {
261
1099
  const [search, setSearch] = useState("");
262
1100
  const [page, setPage] = useState(0);
263
1101
  const [openMa, setOpenMa] = useState<string | null>(null);
1102
+ // ONE task state per record — the register column, its peek popover, and the
1103
+ // drawer all read and write the same list. Paid state lifts the same way:
1104
+ // settling the ledger in the drawer flips the row's Unpaid/Print/menu live.
1105
+ const [taskMap, setTaskMap] = useState<Record<string, RecTask[]>>({});
1106
+ const tasksOf = (hs: HoSo) => taskMap[hs.ma] ?? seedTasks(hs);
1107
+ const setTasksOf = (hs: HoSo, next: RecTask[]) => setTaskMap((prev) => ({ ...prev, [hs.ma]: next }));
1108
+ const [paidMap, setPaidMap] = useState<Record<string, boolean>>({});
1109
+ const daThuOf = (r: HoSo) => paidMap[r.ma] ?? r.daThu;
1110
+ const [dismissedMap, setDismissedMap] = useState<Record<string, string[]>>({});
1111
+ const suggestionsOf = (r: HoSo) => HANDOFF_TASKS.filter((l) => !(dismissedMap[r.ma] ?? []).includes(l));
1112
+ const dismissSuggestion = (r: HoSo, label: string) =>
1113
+ setDismissedMap((prev) => {
1114
+ const cur = prev[r.ma] ?? [];
1115
+ return cur.includes(label) ? prev : { ...prev, [r.ma]: [...cur, label] };
1116
+ });
1117
+ // The register rows are state: the create dialog PREPENDS a real row and
1118
+ // opens its drawer — create-then-refine (the new record's checklist starts
1119
+ // EMPTY; the commons wait as ghost suggestions).
1120
+ const [rows, setRows] = useState<HoSo[]>(HO_SO);
1121
+ const nextMa = useRef(64);
1122
+ const createRecord = (khach: string, dienThoai: string, phi: number) => {
1123
+ const now = new Date();
1124
+ const ma = `RC-2026-${String(nextMa.current++).padStart(4, "0")}`;
1125
+ const row: HoSo = {
1126
+ ma,
1127
+ khach,
1128
+ dienThoai,
1129
+ trangThai: "dangxuly",
1130
+ phi,
1131
+ daThu: false,
1132
+ ngayNhan: `${String(now.getDate()).padStart(2, "0")}/${String(now.getMonth() + 1).padStart(2, "0")}`,
1133
+ phuTrach: "Sarah Chen",
1134
+ };
1135
+ setRows((prev) => [row, ...prev]);
1136
+ setTaskMap((prev) => ({ ...prev, [ma]: [] }));
1137
+ // Reveal what was just created: clear every filter that could hide the
1138
+ // new row, land on page 1, open its drawer.
1139
+ setTab("all");
1140
+ setSearch("");
1141
+ setAssignee([]);
1142
+ setFeeStatus(null);
1143
+ setSort(null);
1144
+ setPage(0);
1145
+ setOpenMa(ma);
1146
+ };
264
1147
  const [sort, setSort] = useState<SortState | null>(null);
265
1148
  const [assignee, setAssignee] = useState<string[]>([]);
266
1149
  const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
@@ -282,19 +1165,20 @@ export function TplItemList() {
282
1165
  setPage(0);
283
1166
  };
284
1167
 
285
- const dem = (t: TrangThai) => HO_SO.filter((r) => r.trangThai === t).length;
286
- const phiDaThu = HO_SO.filter((r) => r.daThu).reduce((s, r) => s + r.phi, 0);
287
-
288
1168
  // Search + status tab filter FIRST, then paging over the filtered ordering.
1169
+ // The SummaryLine reads the pre-tab subset: it respects search/assignee/fee
1170
+ // but keeps all three status counts visible whatever tab is active.
289
1171
  const q = search.trim().toLowerCase();
290
- const filtered = HO_SO.filter((r) => {
291
- if (tab !== "all" && r.trangThai !== tab) return false;
1172
+ const preTab = rows.filter((r) => {
292
1173
  if (assignee.length > 0 && !assignee.includes(r.phuTrach)) return false;
293
- if (feeStatus === "paid" && !r.daThu) return false;
294
- if (feeStatus === "unpaid" && r.daThu) return false;
1174
+ if (feeStatus === "paid" && !daThuOf(r)) return false;
1175
+ if (feeStatus === "unpaid" && daThuOf(r)) return false;
295
1176
  if (q && !`${r.ma} ${r.khach} ${r.dienThoai}`.toLowerCase().includes(q)) return false;
296
1177
  return true;
297
1178
  });
1179
+ const filtered = preTab.filter((r) => tab === "all" || r.trangThai === tab);
1180
+ const dem = (t: TrangThai) => preTab.filter((r) => r.trangThai === t).length;
1181
+ const phiDaThu = preTab.filter((r) => daThuOf(r)).reduce((s, r) => s + r.phi, 0);
298
1182
  const assigneeSummary = selectSummary(assignee, ASSIGNEES);
299
1183
  // Sort the filtered set (one column), then page over that ordering.
300
1184
  const sorted = sortBy(filtered, sort, (r, key) =>
@@ -319,7 +1203,7 @@ export function TplItemList() {
319
1203
 
320
1204
  // The drawer sequences over the CURRENTLY-VISIBLE page slice. If the open
321
1205
  // case falls out of view, the chevrons disappear but the drawer stays.
322
- const openRow = openMa === null ? null : HO_SO.find((r) => r.ma === openMa) ?? null;
1206
+ const openRow = openMa === null ? null : rows.find((r) => r.ma === openMa) ?? null;
323
1207
  const seqIndex = openRow ? pageRows.findIndex((r) => r.ma === openRow.ma) : -1;
324
1208
 
325
1209
  return (
@@ -328,8 +1212,8 @@ export function TplItemList() {
328
1212
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
329
1213
  {/* header — title only; the New case action lives on the toolbar row */}
330
1214
  <View style={{ gap: 2 }}>
331
- <Text size="xl" weight="semibold">Cases & documents</Text>
332
- <Text size="sm" color="muted">Track each case's status and print the right document for its stage</Text>
1215
+ <Text size="xl" weight="semibold">Records</Text>
1216
+ <Text size="sm" color="muted">Track each record's status and print the right document for its stage</Text>
333
1217
  </View>
334
1218
 
335
1219
  {/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
@@ -341,13 +1225,13 @@ export function TplItemList() {
341
1225
  placeholder="Search by ID, name, or phone…"
342
1226
  value={search}
343
1227
  onChangeText={doiTimKiem}
344
- accessibilityLabel="Search cases"
1228
+ accessibilityLabel="Search records"
345
1229
  />
346
1230
  </View>
347
1231
  <Select
348
1232
  placeholder="Status"
349
1233
  options={TABS.map((t) => ({
350
- label: `${t.label} · ${t.value === "all" ? HO_SO.length : dem(t.value)}`,
1234
+ label: `${t.label} · ${t.value === "all" ? preTab.length : dem(t.value)}`,
351
1235
  value: t.value,
352
1236
  }))}
353
1237
  value={tab}
@@ -387,14 +1271,14 @@ export function TplItemList() {
387
1271
  )}
388
1272
  </FilterChip>
389
1273
  </View>
390
- <TaoHoSoForm />
1274
+ <CreateRecordDialog onCreate={createRecord} />
391
1275
  </View>
392
1276
 
393
- {/* light summary of the filtered register each count drills into the matching tab above */}
1277
+ {/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
394
1278
  <SummaryLine
395
1279
  items={[
396
1280
  { label: "processing", value: dem("dangxuly") },
397
- { label: "awaiting docs", value: dem("chobosung"), tone: "warning", info: "Cases waiting on customer paperwork — their record isn't complete, so they can't be ticked for export until the docs arrive." },
1281
+ { label: "awaiting docs", value: dem("chobosung"), tone: "warning", info: "Records waiting on customer paperwork — they aren't complete, so they can't be ticked for export until the docs arrive." },
398
1282
  { label: "complete", value: dem("hoantat") },
399
1283
  { label: "fees collected", value: phiDaThu, format: "currency", compact: true },
400
1284
  ]}
@@ -410,7 +1294,7 @@ export function TplItemList() {
410
1294
  onSort={onSort}
411
1295
  selectAll={
412
1296
  <CheckboxInput
413
- accessibilityLabel="Select all cases on this page"
1297
+ accessibilityLabel="Select all records on this page"
414
1298
  checked={sel.allSelected(selectableIds)}
415
1299
  indeterminate={sel.indeterminate(selectableIds)}
416
1300
  disabled={selectableIds.length === 0}
@@ -419,18 +1303,18 @@ export function TplItemList() {
419
1303
  }
420
1304
  >
421
1305
  {pageRows.map((r) => (
422
- <HoSoRow key={r.ma} hs={r} selected={r.ma === openMa} marked={sel.has(r.ma)} selectable={isSelectable(r)} onToggle={(on) => sel.toggle(r.ma, on)} onPress={() => setOpenMa(r.ma)} />
1306
+ <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)} />
423
1307
  ))}
424
1308
  </Table>
425
1309
  {pageRows.length === 0 ? (
426
1310
  <>
427
1311
  <Divider />
428
- <EmptyState message="No matching cases" hint="Try another keyword or switch status" />
1312
+ <EmptyState message="No matching records" hint="Try another keyword or switch status" />
429
1313
  </>
430
1314
  ) : null}
431
1315
  <CardFooter>
432
1316
  <Text size="sm" color="muted" tabular style={{ flex: 1 }}>
433
- {`${filtered.length} cases · Total fees ${formatMoney(tongPhi)}`}
1317
+ {`${filtered.length} records · Total fees ${formatMoney(tongPhi)}`}
434
1318
  </Text>
435
1319
  <Pagination
436
1320
  page={page}
@@ -448,25 +1332,32 @@ export function TplItemList() {
448
1332
  {/* bulk action over the ticked cases — the shared floating bar */}
449
1333
  <FloatingActionBar
450
1334
  count={sel.count}
451
- label={sel.count === 1 ? "case selected" : "cases selected"}
1335
+ label={sel.count === 1 ? "record selected" : "records selected"}
452
1336
  onClear={sel.clear}
453
1337
  >
454
1338
  <Button title="Export" color="primary" onPress={() => {}} />
455
1339
  </FloatingActionBar>
456
1340
 
457
- {/* the case workspace — sequenced over the visible page ordering */}
1341
+ {/* the case workspace — sequenced over the visible page ordering. The
1342
+ ScreenRouter wraps the WHOLE drawer so the header can read the stack
1343
+ (back button + the pushed record's title live in the drawer chrome,
1344
+ like the frontend's record drawers). Keyed by record so ◀ ▶ resets. */}
458
1345
  {openRow ? (
459
- <Drawer
460
- open
461
- onOpenChange={(o) => !o && setOpenMa(null)}
462
- title={openRow.khach}
463
- width={440}
464
- onPrev={seqIndex > 0 ? () => setOpenMa(pageRows[seqIndex - 1].ma) : undefined}
465
- onNext={seqIndex >= 0 && seqIndex < pageRows.length - 1 ? () => setOpenMa(pageRows[seqIndex + 1].ma) : undefined}
466
- position={seqIndex >= 0 ? `${seqIndex + 1}/${pageRows.length}` : undefined}
467
- >
468
- <HoSoWorkspace key={openRow.ma} hs={openRow} />
469
- </Drawer>
1346
+ <ScreenRouter key={openRow.ma}>
1347
+ <RecordDrawer
1348
+ hs={openRow}
1349
+ suggestions={suggestionsOf(openRow)}
1350
+ onDismissSuggestion={(l) => dismissSuggestion(openRow, l)}
1351
+ daThu={daThuOf(openRow)}
1352
+ onPaid={() => setPaidMap((prev) => ({ ...prev, [openRow.ma]: true }))}
1353
+ tasks={tasksOf(openRow)}
1354
+ onTasksChange={(next) => setTasksOf(openRow, next)}
1355
+ onClose={() => setOpenMa(null)}
1356
+ onPrev={seqIndex > 0 ? () => setOpenMa(pageRows[seqIndex - 1].ma) : undefined}
1357
+ onNext={seqIndex >= 0 && seqIndex < pageRows.length - 1 ? () => setOpenMa(pageRows[seqIndex + 1].ma) : undefined}
1358
+ position={seqIndex >= 0 ? `${seqIndex + 1}/${pageRows.length}` : undefined}
1359
+ />
1360
+ </ScreenRouter>
470
1361
  ) : null}
471
1362
  </View>
472
1363
  );