@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.
- package/AGENTS.md +177 -70
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +16 -13
- package/examples/tpl_tasks.tsx +15 -28
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +8 -7
- package/src/capture_row.tsx +59 -0
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/detail_row.tsx +137 -10
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +25 -0
- package/src/page_header.tsx +0 -2
- package/src/popover_nav.tsx +40 -0
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/suggestion_chip.tsx +47 -0
- package/src/trend_footer.tsx +3 -1
- package/src/use_screen_size.ts +1 -1
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_wizard.tsx +0 -223
- package/src/animation_horizontal_slide.tsx +0 -75
- package/src/form_time_picker.tsx +0 -22
- package/src/highlighted_text.tsx +0 -92
- package/src/menu_title.tsx +0 -15
- package/src/pager_view.tsx +0 -167
- 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 {
|
|
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
|
|
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 ·
|
|
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
|
|
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: "
|
|
77
|
-
{ ma: "
|
|
78
|
-
{ ma: "
|
|
79
|
-
{ ma: "
|
|
80
|
-
{ ma: "
|
|
81
|
-
{ ma: "
|
|
82
|
-
{ ma: "
|
|
83
|
-
{ ma: "
|
|
84
|
-
{ ma: "
|
|
85
|
-
{ ma: "
|
|
86
|
-
{ ma: "
|
|
87
|
-
{ ma: "
|
|
88
|
-
{ ma: "
|
|
89
|
-
{ ma: "
|
|
90
|
-
{ ma: "
|
|
91
|
-
{ ma: "
|
|
92
|
-
{ ma: "
|
|
93
|
-
{ ma: "
|
|
94
|
-
{ ma: "
|
|
95
|
-
{ ma: "
|
|
96
|
-
{ ma: "
|
|
97
|
-
{ ma: "
|
|
98
|
-
{ ma: "
|
|
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: "
|
|
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:
|
|
119
|
-
{ key: "
|
|
120
|
-
{ key: "
|
|
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
|
|
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:
|
|
135
|
-
{ key: "sua", label: "Edit
|
|
136
|
-
{ key: "huy", label: "Cancel
|
|
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
|
|
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={
|
|
172
|
-
{hs.
|
|
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
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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="
|
|
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
|
|
220
|
-
*
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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="
|
|
248
|
-
<NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="
|
|
1084
|
+
<FormField label="Service fee">
|
|
1085
|
+
<NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Service fee" />
|
|
249
1086
|
</FormField>
|
|
250
1087
|
</View>
|
|
251
|
-
<
|
|
252
|
-
<Button title="
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
|
294
|
-
if (feeStatus === "unpaid" && r
|
|
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 :
|
|
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">
|
|
332
|
-
<Text size="sm" color="muted">Track each
|
|
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
|
|
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" ?
|
|
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
|
-
<
|
|
1274
|
+
<CreateRecordDialog onCreate={createRecord} />
|
|
391
1275
|
</View>
|
|
392
1276
|
|
|
393
|
-
{/* light summary of the filtered register
|
|
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: "
|
|
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
|
|
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
|
|
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}
|
|
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 ? "
|
|
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
|
-
<
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
);
|