@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.
- package/AGENTS.md +8 -1
- package/MIGRATION.md +37 -0
- package/docs/catalog.md +57 -12
- package/docs/composition.md +63 -3
- package/docs/data_entry.md +19 -1
- package/docs/templates.md +24 -28
- package/examples/tpl_item_list.tsx +19 -768
- package/examples/tpl_record.tsx +515 -415
- package/examples/tpl_task_board.tsx +2 -2
- package/package.json +5 -2
- package/src/deadline.ts +157 -0
- package/src/filter_chip.tsx +2 -2
- package/src/locale.tsx +16 -0
- package/src/option_list.tsx +3 -3
- package/src/reference_field.tsx +174 -0
- package/src/table.tsx +9 -0
- package/src/text_button.tsx +139 -0
- package/src/text_link.tsx +24 -14
|
@@ -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 {
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)}
|
|
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}
|