@lotics/ui 3.5.0 → 4.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 +323 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/callout.tsx +50 -17
- package/src/combobox.tsx +22 -6
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
|
+
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
6
|
+
import { Alert } from "@lotics/ui/alert";
|
|
7
|
+
import { Badge } from "@lotics/ui/badge";
|
|
8
|
+
import { Button } from "@lotics/ui/button";
|
|
9
|
+
import { Card, CardFooter } from "@lotics/ui/card";
|
|
10
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
11
|
+
import { Divider } from "@lotics/ui/divider";
|
|
12
|
+
import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
13
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
14
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
15
|
+
import { type IconName } from "@lotics/ui/icon";
|
|
16
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
17
|
+
import { NumberInput } from "@lotics/ui/number_input";
|
|
18
|
+
import { Pagination } from "@lotics/ui/pagination";
|
|
19
|
+
import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
|
|
20
|
+
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
21
|
+
import { SearchInput } from "@lotics/ui/search_input";
|
|
22
|
+
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
23
|
+
import { FilterPill, selectSummary } from "@lotics/ui/filter_pill";
|
|
24
|
+
import { PickerMenu } from "@lotics/ui/picker_menu";
|
|
25
|
+
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
26
|
+
import { Combobox } from "@lotics/ui/combobox";
|
|
27
|
+
import type { PickerOption } from "@lotics/ui/picker";
|
|
28
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Template · Cases & documents — the case register. Two jobs on one screen:
|
|
32
|
+
// track each case through its statuses, AND generate/print the paper the
|
|
33
|
+
// current stage needs (payment slip while the fee is unpaid, then the stage's
|
|
34
|
+
// own document). Grammar: header band (+ New case popover form) · KPI strip
|
|
35
|
+
// (counts drill into the status pills) · ChipGroup filters the register · ONE banded
|
|
36
|
+
// card: search toolbar → eyebrow band → pressable list rows (press = the
|
|
37
|
+
// sequenced workspace Drawer, ⋯ = the row's real actions) → footer with the
|
|
38
|
+
// filtered totals + Pagination over the filtered set (pageSize 8).
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
// The customer book the new-case form searches — find-or-create (pick an existing
|
|
42
|
+
// customer or coin a new one) instead of typing a raw name that risks a duplicate.
|
|
43
|
+
const CUSTOMER_OPTIONS: PickerOption[] = [
|
|
44
|
+
"Northwind Packaging", "Crestline Foods", "Atlas Components", "Summit Logistics",
|
|
45
|
+
"Michael Torres", "Anna Whitfield", "Robert Hayes", "Karen Mitchell",
|
|
46
|
+
].map((name) => ({ value: name, label: name }));
|
|
47
|
+
|
|
48
|
+
type TrangThai = "dangxuly" | "chobosung" | "hoantat";
|
|
49
|
+
type Tab = "all" | TrangThai;
|
|
50
|
+
|
|
51
|
+
const TRANG_THAI: Record<TrangThai, { label: string; color: ColorName; doc: { title: string; icon: IconName } }> = {
|
|
52
|
+
dangxuly: { label: "Processing", color: "blue", doc: { title: "Print receipt", icon: "file-text" } },
|
|
53
|
+
chobosung: { label: "Awaiting docs", color: "amber", doc: { title: "Print docs request", icon: "file-text" } },
|
|
54
|
+
hoantat: { label: "Complete", color: "emerald", doc: { title: "Print result", icon: "file-down" } },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface HoSo {
|
|
58
|
+
ma: string;
|
|
59
|
+
khach: string;
|
|
60
|
+
dienThoai: string;
|
|
61
|
+
trangThai: TrangThai;
|
|
62
|
+
phi: number;
|
|
63
|
+
daThu: boolean;
|
|
64
|
+
ngayThu?: string;
|
|
65
|
+
ngayNhan: string;
|
|
66
|
+
phuTrach: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const HO_SO: HoSo[] = [
|
|
70
|
+
{ ma: "CS-2026-0041", khach: "Michael Torres", dienThoai: "(555) 384-7560", trangThai: "dangxuly", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "02/06", phuTrach: "Sarah Chen" },
|
|
71
|
+
{ ma: "CS-2026-0042", khach: "Anna Whitfield", dienThoai: "(555) 220-4158", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "03/06", phuTrach: "Sarah Chen" },
|
|
72
|
+
{ ma: "CS-2026-0043", khach: "Northwind Packaging", dienThoai: "(555) 558-2143", trangThai: "chobosung", phi: 4_200_000, daThu: true, ngayThu: "05/06", ngayNhan: "04/06", phuTrach: "David Park" },
|
|
73
|
+
{ ma: "CS-2026-0044", khach: "Robert Hayes", dienThoai: "(555) 471-8026", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "29/05", ngayNhan: "28/05", phuTrach: "Sarah Chen" },
|
|
74
|
+
{ ma: "CS-2026-0045", khach: "Karen Mitchell", dienThoai: "(555) 605-3317", trangThai: "dangxuly", phi: 3_100_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "David Park" },
|
|
75
|
+
{ ma: "CS-2026-0046", khach: "Daniel Brooks", dienThoai: "(555) 112-9084", trangThai: "chobosung", phi: 1_800_000, daThu: false, ngayNhan: "06/06", phuTrach: "Sarah Chen" },
|
|
76
|
+
{ ma: "CS-2026-0047", khach: "Crestline Foods", dienThoai: "(555) 446-2708", trangThai: "hoantat", phi: 5_600_000, daThu: true, ngayThu: "31/05", ngayNhan: "30/05", phuTrach: "David Park" },
|
|
77
|
+
{ ma: "CS-2026-0048", khach: "Laura Bennett", dienThoai: "(555) 833-5192", trangThai: "dangxuly", phi: 2_500_000, daThu: false, ngayNhan: "09/06", phuTrach: "James Walker" },
|
|
78
|
+
{ ma: "CS-2026-0049", khach: "Thomas Reed", dienThoai: "(555) 760-3844", trangThai: "hoantat", phi: 1_800_000, daThu: true, ngayThu: "02/06", ngayNhan: "27/05", phuTrach: "James Walker" },
|
|
79
|
+
{ ma: "CS-2026-0050", khach: "Kevin Doyle", dienThoai: "(555) 905-1273", trangThai: "dangxuly", phi: 4_200_000, daThu: true, ngayThu: "10/06", ngayNhan: "10/06", phuTrach: "James Walker" },
|
|
80
|
+
{ ma: "CS-2026-0051", khach: "Atlas Components", dienThoai: "(555) 271-6355", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "06/06", ngayNhan: "05/06", phuTrach: "Maria Lopez" },
|
|
81
|
+
{ ma: "CS-2026-0052", khach: "Rachel Foster", dienThoai: "(555) 540-2866", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "04/06", ngayNhan: "03/06", phuTrach: "Sarah Chen" },
|
|
82
|
+
{ ma: "CS-2026-0053", khach: "Patrick Shaw", dienThoai: "(555) 119-4727", trangThai: "hoantat", phi: 1_200_000, daThu: true, ngayThu: "30/05", ngayNhan: "26/05", phuTrach: "David Park" },
|
|
83
|
+
{ ma: "CS-2026-0054", khach: "Summit Logistics", dienThoai: "(555) 662-8059", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "09/06", phuTrach: "Maria Lopez" },
|
|
84
|
+
{ ma: "CS-2026-0055", khach: "Brian Keller", dienThoai: "(555) 484-9131", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "07/06", phuTrach: "James Walker" },
|
|
85
|
+
{ ma: "CS-2026-0056", khach: "Emily Carter", dienThoai: "(555) 358-0274", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "03/06", ngayNhan: "29/05", phuTrach: "Sarah Chen" },
|
|
86
|
+
{ ma: "CS-2026-0057", khach: "Jason Pratt", dienThoai: "(555) 705-4693", trangThai: "dangxuly", phi: 1_200_000, daThu: true, ngayThu: "08/06", ngayNhan: "08/06", phuTrach: "David Park" },
|
|
87
|
+
{ ma: "CS-2026-0058", khach: "Beacon Supplies", dienThoai: "(555) 813-2460", trangThai: "hoantat", phi: 4_200_000, daThu: true, ngayThu: "01/06", ngayNhan: "28/05", phuTrach: "Maria Lopez" },
|
|
88
|
+
{ ma: "CS-2026-0059", khach: "Nina Alvarez", dienThoai: "(555) 270-1548", trangThai: "dangxuly", phi: 1_800_000, daThu: false, ngayNhan: "10/06", phuTrach: "Sarah Chen" },
|
|
89
|
+
{ ma: "CS-2026-0060", khach: "Steven Drake", dienThoai: "(555) 593-7186", trangThai: "chobosung", phi: 2_500_000, daThu: true, ngayThu: "09/06", ngayNhan: "08/06", phuTrach: "James Walker" },
|
|
90
|
+
{ ma: "CS-2026-0061", khach: "Harbor Industries", dienThoai: "(555) 047-5829", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Maria Lopez" },
|
|
91
|
+
{ ma: "CS-2026-0062", khach: "Grace Holloway", dienThoai: "(555) 826-3050", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "05/06", ngayNhan: "31/05", phuTrach: "David Park" },
|
|
92
|
+
{ ma: "CS-2026-0063", khach: "Olivia Manning", dienThoai: "(555) 631-2904", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "11/06", phuTrach: "Sarah Chen" },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const TABS: { value: Tab; label: string }[] = [
|
|
96
|
+
{ value: "all", label: "All" },
|
|
97
|
+
{ value: "dangxuly", label: "Processing" },
|
|
98
|
+
{ value: "chobosung", label: "Awaiting docs" },
|
|
99
|
+
{ value: "hoantat", label: "Complete" },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const PAGE_SIZE = 8;
|
|
103
|
+
|
|
104
|
+
// Distinct case handlers — the secondary filter dimension (a multi-select FilterPill).
|
|
105
|
+
const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label: n, value: n }));
|
|
106
|
+
|
|
107
|
+
// Columns defined ONCE — Table renders the header from these and each
|
|
108
|
+
// TableCell takes its width/align by position; the ⋯ is the trailing gutter.
|
|
109
|
+
const COLUMNS: TableColumn[] = [
|
|
110
|
+
{ key: "ma", label: "Case ID", width: 122, sortable: true },
|
|
111
|
+
{ key: "khach", label: "Customer", flex: 1, sortable: true },
|
|
112
|
+
{ key: "trangThai", label: "Status", width: 122, sortable: true },
|
|
113
|
+
{ key: "phi", label: "Fee", width: 138, align: "right", sortable: true },
|
|
114
|
+
{ key: "ngayNhan", label: "Received", width: 88, sortable: true },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// DOCUMENT — the paper the row needs NOW: while the fee is unpaid the
|
|
118
|
+
// paper that matters is the payment slip; once paid, the stage's own document.
|
|
119
|
+
function docFor(r: HoSo): { title: string; icon: IconName } {
|
|
120
|
+
return r.daThu ? TRANG_THAI[r.trangThai].doc : { title: "Print payment slip", icon: "receipt" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The ⋯ menu carries the row's REAL actions: the stage document first, then
|
|
124
|
+
// payment (disabled once collected), edit, and the destructive cancel last.
|
|
125
|
+
function menuFor(r: HoSo): ActionMenuItem[] {
|
|
126
|
+
const doc = docFor(r);
|
|
127
|
+
return [
|
|
128
|
+
{ key: "chung_tu", label: doc.title, icon: doc.icon, onPress: () => {} },
|
|
129
|
+
{ key: "thanh_toan", label: "Record payment", icon: "credit-card", disabled: r.daThu, onPress: () => {} },
|
|
130
|
+
{ key: "sua", label: "Edit case", icon: "pencil", onPress: () => {} },
|
|
131
|
+
{ key: "huy", label: "Cancel case", icon: "trash", danger: true, onPress: () => Alert.alert("Cancel this case?", "The case is closed and removed from the active register — it can't be undone.", [{ text: "Keep case", style: "cancel" }, { text: "Cancel case", style: "destructive", onPress: () => {} }]) },
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function HoSoRow({ hs, selected, onPress }: { hs: HoSo; selected: boolean; onPress: () => void }) {
|
|
136
|
+
return (
|
|
137
|
+
<TableRow
|
|
138
|
+
onPress={onPress}
|
|
139
|
+
selected={selected}
|
|
140
|
+
minHeight={56}
|
|
141
|
+
accessibilityLabel={`Open case ${hs.ma} — ${hs.khach}`}
|
|
142
|
+
trailing={<ActionMenu items={menuFor(hs)} accessibilityLabel={`Actions for ${hs.ma}`} />}
|
|
143
|
+
>
|
|
144
|
+
<TableCell>
|
|
145
|
+
<Text size="sm" weight="semibold" tabular>{hs.ma}</Text>
|
|
146
|
+
</TableCell>
|
|
147
|
+
<TableCell>
|
|
148
|
+
<View style={{ gap: 2 }}>
|
|
149
|
+
<Text size="sm" weight="medium" numberOfLines={1}>{hs.khach}</Text>
|
|
150
|
+
<Text size="xs" color="muted" tabular>{hs.dienThoai}</Text>
|
|
151
|
+
</View>
|
|
152
|
+
</TableCell>
|
|
153
|
+
<TableCell>
|
|
154
|
+
<Badge variant="dot" label={TRANG_THAI[hs.trangThai].label} color={TRANG_THAI[hs.trangThai].color} />
|
|
155
|
+
</TableCell>
|
|
156
|
+
<TableCell>
|
|
157
|
+
<View style={{ gap: 2, alignItems: "flex-end" }}>
|
|
158
|
+
<Text size="sm" tabular>{formatMoney(hs.phi)}</Text>
|
|
159
|
+
<Text size="xs" color={hs.daThu ? "muted" : "danger"} tabular>
|
|
160
|
+
{hs.daThu ? `Paid ${hs.ngayThu}` : "Unpaid"}
|
|
161
|
+
</Text>
|
|
162
|
+
</View>
|
|
163
|
+
</TableCell>
|
|
164
|
+
<TableCell>
|
|
165
|
+
<Text size="sm" tabular>{hs.ngayNhan}</Text>
|
|
166
|
+
</TableCell>
|
|
167
|
+
</TableRow>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The case workspace behind a register row — the summary fields plus the
|
|
172
|
+
// stage document and the Open case action. Keyed by id from the caller so
|
|
173
|
+
// per-record state resets as ◀ ▶ steps the sequence.
|
|
174
|
+
function HoSoWorkspace({ hs }: { hs: HoSo }) {
|
|
175
|
+
const doc = docFor(hs);
|
|
176
|
+
return (
|
|
177
|
+
<>
|
|
178
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
|
|
179
|
+
<View style={{ gap: 8 }}>
|
|
180
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
181
|
+
<Text size="lg" weight="semibold" tabular>{hs.ma}</Text>
|
|
182
|
+
<Badge label={TRANG_THAI[hs.trangThai].label} color={TRANG_THAI[hs.trangThai].color} />
|
|
183
|
+
</View>
|
|
184
|
+
<Text size="sm" color="muted">{`Received ${hs.ngayNhan} · ${hs.phuTrach}`}</Text>
|
|
185
|
+
</View>
|
|
186
|
+
<Divider />
|
|
187
|
+
<View style={{ gap: 8 }}>
|
|
188
|
+
<DetailRow label="Customer" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{hs.khach}</Text></DetailRow>
|
|
189
|
+
<DetailRow label="Phone" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{hs.dienThoai}</Text></DetailRow>
|
|
190
|
+
<DetailRow label="Assignee" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{hs.phuTrach}</Text></DetailRow>
|
|
191
|
+
<DetailRow label="Case fee" labelWidth={120} minHeight={24}><Text size="sm" color="default" tabular>{formatMoney(hs.phi)}</Text></DetailRow>
|
|
192
|
+
<DetailRow label="Payment" labelWidth={120} minHeight={24}><Text size="sm" color={!hs.daThu ? "danger" : "default"} tabular>{hs.daThu ? `Paid ${hs.ngayThu}` : "Unpaid"}</Text></DetailRow>
|
|
193
|
+
</View>
|
|
194
|
+
</ScrollView>
|
|
195
|
+
{/* pinned footer — the stage's paper + the way into the full record */}
|
|
196
|
+
<DrawerFooter>
|
|
197
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>
|
|
198
|
+
{hs.daThu ? "Fee collected — print the current stage's document." : "Fee unpaid — print the payment slip before proceeding."}
|
|
199
|
+
</Text>
|
|
200
|
+
<Button title={doc.title} icon={doc.icon} color="secondary" onPress={() => {}} />
|
|
201
|
+
<Button title="Open case" color="primary" onPress={() => {}} />
|
|
202
|
+
</DrawerFooter>
|
|
203
|
+
</>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** New case is a 2-field gate — a popover form anchored to the primary
|
|
208
|
+
* button (Dialog would be overweight for customer + fee). */
|
|
209
|
+
function TaoHoSoForm() {
|
|
210
|
+
const [khach, setKhach] = useState("");
|
|
211
|
+
const [phi, setPhi] = useState<number | null>(null);
|
|
212
|
+
return (
|
|
213
|
+
<Popover side="bottom" align="end">
|
|
214
|
+
<PopoverTrigger>
|
|
215
|
+
<Button title="New case" color="primary" />
|
|
216
|
+
</PopoverTrigger>
|
|
217
|
+
<PopoverContent style={{ width: 320 }} disableBodyScroll>
|
|
218
|
+
<View style={{ gap: 12 }}>
|
|
219
|
+
<View style={{ gap: 2 }}>
|
|
220
|
+
<Text size="sm" weight="semibold">Create a new case</Text>
|
|
221
|
+
<Text size="xs" color="muted">The case starts in Processing — print the receipt right after creating it.</Text>
|
|
222
|
+
</View>
|
|
223
|
+
<FormField label="Customer">
|
|
224
|
+
<Combobox
|
|
225
|
+
icon="search"
|
|
226
|
+
options={CUSTOMER_OPTIONS}
|
|
227
|
+
onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
|
|
228
|
+
allowCustom
|
|
229
|
+
customOptionPlacement="top"
|
|
230
|
+
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
231
|
+
placeholder="Search customers, or add a new one…"
|
|
232
|
+
accessibilityLabel="Customer"
|
|
233
|
+
/>
|
|
234
|
+
</FormField>
|
|
235
|
+
<FormField label="Case fee">
|
|
236
|
+
<NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Case fee" />
|
|
237
|
+
</FormField>
|
|
238
|
+
</View>
|
|
239
|
+
<PopoverFooter>
|
|
240
|
+
<Button title="Create case" color="primary" disabled={khach === ""} onPress={() => {}} />
|
|
241
|
+
</PopoverFooter>
|
|
242
|
+
</PopoverContent>
|
|
243
|
+
</Popover>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function TplDossier() {
|
|
248
|
+
const [tab, setTab] = useState<Tab>("all");
|
|
249
|
+
const [search, setSearch] = useState("");
|
|
250
|
+
const [page, setPage] = useState(0);
|
|
251
|
+
const [openMa, setOpenMa] = useState<string | null>(null);
|
|
252
|
+
const [sort, setSort] = useState<SortState | null>(null);
|
|
253
|
+
const [assignee, setAssignee] = useState<string[]>([]);
|
|
254
|
+
const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
|
|
255
|
+
|
|
256
|
+
// Filter / sort changes reset paging — page 3 of the old ordering is
|
|
257
|
+
// meaningless under the new one.
|
|
258
|
+
const chonTab = (t: Tab) => {
|
|
259
|
+
setTab(t);
|
|
260
|
+
setPage(0);
|
|
261
|
+
};
|
|
262
|
+
const doiTimKiem = (s: string) => {
|
|
263
|
+
setSearch(s);
|
|
264
|
+
setPage(0);
|
|
265
|
+
};
|
|
266
|
+
// One sort at a time; the third toggle on a column clears it.
|
|
267
|
+
const onSort = (key: string) => {
|
|
268
|
+
setSort(cycleSort(sort, key));
|
|
269
|
+
setPage(0);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const dem = (t: TrangThai) => HO_SO.filter((r) => r.trangThai === t).length;
|
|
273
|
+
const phiDaThu = HO_SO.filter((r) => r.daThu).reduce((s, r) => s + r.phi, 0);
|
|
274
|
+
const chuaThu = HO_SO.filter((r) => !r.daThu);
|
|
275
|
+
|
|
276
|
+
// Search + status tab filter FIRST, then paging over the filtered ordering.
|
|
277
|
+
const q = search.trim().toLowerCase();
|
|
278
|
+
const filtered = HO_SO.filter((r) => {
|
|
279
|
+
if (tab !== "all" && r.trangThai !== tab) return false;
|
|
280
|
+
if (assignee.length > 0 && !assignee.includes(r.phuTrach)) return false;
|
|
281
|
+
if (feeStatus === "paid" && !r.daThu) return false;
|
|
282
|
+
if (feeStatus === "unpaid" && r.daThu) return false;
|
|
283
|
+
if (q && !`${r.ma} ${r.khach} ${r.dienThoai}`.toLowerCase().includes(q)) return false;
|
|
284
|
+
return true;
|
|
285
|
+
});
|
|
286
|
+
const assigneeSummary = selectSummary(assignee, ASSIGNEES);
|
|
287
|
+
// Sort the filtered set (one column), then page over that ordering.
|
|
288
|
+
const sorted = sortBy(filtered, sort, (r, key) =>
|
|
289
|
+
key === "khach" ? r.khach.toLowerCase()
|
|
290
|
+
: key === "trangThai" ? TRANG_THAI[r.trangThai].label
|
|
291
|
+
: key === "phi" ? r.phi
|
|
292
|
+
: key === "ngayNhan" ? r.ngayNhan.split("/").reverse().join("")
|
|
293
|
+
: r.ma,
|
|
294
|
+
);
|
|
295
|
+
const tongPhi = filtered.reduce((s, r) => s + r.phi, 0);
|
|
296
|
+
const pageRows = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
297
|
+
const hasMore = (page + 1) * PAGE_SIZE < sorted.length;
|
|
298
|
+
|
|
299
|
+
// The drawer sequences over the CURRENTLY-VISIBLE page slice. If the open
|
|
300
|
+
// case falls out of view, the chevrons disappear but the drawer stays.
|
|
301
|
+
const openRow = openMa === null ? null : HO_SO.find((r) => r.ma === openMa) ?? null;
|
|
302
|
+
const seqIndex = openRow ? pageRows.findIndex((r) => r.ma === openRow.ma) : -1;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
306
|
+
<View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
|
|
307
|
+
{/* header band — one primary action: the New case popover form */}
|
|
308
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
309
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
310
|
+
<Text size="xl" weight="semibold">Cases & documents</Text>
|
|
311
|
+
<Text size="sm" color="muted">Track each case's status and print the right document for its stage</Text>
|
|
312
|
+
</View>
|
|
313
|
+
<TaoHoSoForm />
|
|
314
|
+
</View>
|
|
315
|
+
|
|
316
|
+
{/* each count drills into the matching tab below; Fees collected carries
|
|
317
|
+
the chase ("n still unpaid") as its caption */}
|
|
318
|
+
<KPIStrip
|
|
319
|
+
items={[
|
|
320
|
+
{ label: "Processing", value: dem("dangxuly") },
|
|
321
|
+
{ label: "Awaiting docs", value: dem("chobosung"), tone: "warning", info: "Cases waiting on customer paperwork — chase these first; the tab below filters to them." },
|
|
322
|
+
{ label: "Complete", value: dem("hoantat") },
|
|
323
|
+
{
|
|
324
|
+
label: "Fees collected",
|
|
325
|
+
value: phiDaThu,
|
|
326
|
+
format: "currency",
|
|
327
|
+
compact: true,
|
|
328
|
+
caption: `${chuaThu.length} cases unpaid · ${formatMoney(chuaThu.reduce((s, r) => s + r.phi, 0), { compact: true })}`,
|
|
329
|
+
},
|
|
330
|
+
]}
|
|
331
|
+
/>
|
|
332
|
+
|
|
333
|
+
{/* status chips (the hot dimension, inline) + a secondary Assignee
|
|
334
|
+
multi-select FilterPill — the same faceted band as Approvals */}
|
|
335
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
|
336
|
+
<View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
|
|
337
|
+
<SearchInput
|
|
338
|
+
placeholder="Search by ID, name, or phone…"
|
|
339
|
+
value={search}
|
|
340
|
+
onChangeText={doiTimKiem}
|
|
341
|
+
accessibilityLabel="Search cases"
|
|
342
|
+
/>
|
|
343
|
+
</View>
|
|
344
|
+
<ChipGroup
|
|
345
|
+
accessibilityLabel="Filter by status"
|
|
346
|
+
options={TABS.map((t) => ({
|
|
347
|
+
label: `${t.label} · ${t.value === "all" ? HO_SO.length : dem(t.value)}`,
|
|
348
|
+
value: t.value,
|
|
349
|
+
}))}
|
|
350
|
+
value={tab}
|
|
351
|
+
onValueChange={chonTab}
|
|
352
|
+
/>
|
|
353
|
+
<FilterPill
|
|
354
|
+
label="Assignee"
|
|
355
|
+
summary={assigneeSummary}
|
|
356
|
+
onClear={() => { setAssignee([]); setPage(0); }}
|
|
357
|
+
clearLabel="Clear assignee filter"
|
|
358
|
+
>
|
|
359
|
+
<PickerMenu
|
|
360
|
+
multi
|
|
361
|
+
enableSelectAll
|
|
362
|
+
options={ASSIGNEES}
|
|
363
|
+
value={assignee}
|
|
364
|
+
onValueChange={(v) => { setAssignee(v); setPage(0); }}
|
|
365
|
+
/>
|
|
366
|
+
</FilterPill>
|
|
367
|
+
{/* Single-select sibling — the render-prop `close` shuts the popover on
|
|
368
|
+
pick (the multi above stays open to collect several values). */}
|
|
369
|
+
<FilterPill
|
|
370
|
+
label="Fee"
|
|
371
|
+
summary={feeStatus === "paid" ? "Paid" : feeStatus === "unpaid" ? "Unpaid" : undefined}
|
|
372
|
+
onClear={() => { setFeeStatus(null); setPage(0); }}
|
|
373
|
+
clearLabel="Clear fee filter"
|
|
374
|
+
>
|
|
375
|
+
{({ close }) => (
|
|
376
|
+
<PickerMenu
|
|
377
|
+
options={[{ value: "paid", label: "Paid" }, { value: "unpaid", label: "Unpaid" }]}
|
|
378
|
+
value={feeStatus}
|
|
379
|
+
onValueChange={(v) => { setFeeStatus(v); setPage(0); }}
|
|
380
|
+
onRequestClose={close}
|
|
381
|
+
/>
|
|
382
|
+
)}
|
|
383
|
+
</FilterPill>
|
|
384
|
+
</View>
|
|
385
|
+
|
|
386
|
+
{/* the register — eyebrow band (Table header) → list rows → totals + paging */}
|
|
387
|
+
<Card style={{ padding: 0 }}>
|
|
388
|
+
<Table columns={COLUMNS} trailing={40} sort={sort} onSort={onSort}>
|
|
389
|
+
{pageRows.map((r) => (
|
|
390
|
+
<HoSoRow key={r.ma} hs={r} selected={r.ma === openMa} onPress={() => setOpenMa(r.ma)} />
|
|
391
|
+
))}
|
|
392
|
+
</Table>
|
|
393
|
+
{pageRows.length === 0 ? (
|
|
394
|
+
<>
|
|
395
|
+
<Divider />
|
|
396
|
+
<EmptyState message="No matching cases" hint="Try another keyword or switch status" />
|
|
397
|
+
</>
|
|
398
|
+
) : null}
|
|
399
|
+
<CardFooter>
|
|
400
|
+
<Text size="sm" color="muted" tabular style={{ flex: 1 }}>
|
|
401
|
+
{`${filtered.length} cases · Total fees ${formatMoney(tongPhi)}`}
|
|
402
|
+
</Text>
|
|
403
|
+
<Pagination
|
|
404
|
+
page={page}
|
|
405
|
+
pageSize={PAGE_SIZE}
|
|
406
|
+
rowCount={pageRows.length}
|
|
407
|
+
hasMore={hasMore}
|
|
408
|
+
total={filtered.length}
|
|
409
|
+
onPageChange={setPage}
|
|
410
|
+
/>
|
|
411
|
+
</CardFooter>
|
|
412
|
+
</Card>
|
|
413
|
+
</View>
|
|
414
|
+
|
|
415
|
+
{/* the case workspace — sequenced over the visible page ordering */}
|
|
416
|
+
{openRow ? (
|
|
417
|
+
<Drawer
|
|
418
|
+
open
|
|
419
|
+
onOpenChange={(o) => !o && setOpenMa(null)}
|
|
420
|
+
title={openRow.khach}
|
|
421
|
+
width={440}
|
|
422
|
+
onPrev={seqIndex > 0 ? () => setOpenMa(pageRows[seqIndex - 1].ma) : undefined}
|
|
423
|
+
onNext={seqIndex >= 0 && seqIndex < pageRows.length - 1 ? () => setOpenMa(pageRows[seqIndex + 1].ma) : undefined}
|
|
424
|
+
position={seqIndex >= 0 ? `${seqIndex + 1}/${pageRows.length}` : undefined}
|
|
425
|
+
>
|
|
426
|
+
<HoSoWorkspace key={openRow.ma} hs={openRow} />
|
|
427
|
+
</Drawer>
|
|
428
|
+
) : null}
|
|
429
|
+
</ScrollView>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
8
|
+
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
9
|
+
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
10
|
+
import { Divider } from "@lotics/ui/divider";
|
|
11
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
12
|
+
import { Picker } from "@lotics/ui/picker";
|
|
13
|
+
import { Switch } from "@lotics/ui/switch";
|
|
14
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
15
|
+
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Template · Client intake — the multi-section intake form for a new
|
|
18
|
+
// business partner. One banded Card: header (title + why it matters) →
|
|
19
|
+
// three titled fieldsets separated by Dividers → footer per Action Layout
|
|
20
|
+
// (helper text left · Cancel muted + ONE primary right). Fields wrap on a
|
|
21
|
+
// flexBasis grid; every field holds state; Tax ID demonstrates the error band.
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const INDUSTRIES = [
|
|
25
|
+
{ label: "Manufacturing", value: "san_xuat" },
|
|
26
|
+
{ label: "Trading", value: "thuong_mai" },
|
|
27
|
+
{ label: "Construction", value: "xay_dung" },
|
|
28
|
+
{ label: "Transportation", value: "van_tai" },
|
|
29
|
+
{ label: "Packaging", value: "bao_bi" },
|
|
30
|
+
{ label: "Other", value: "khac" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const PAYMENT_TERMS = [
|
|
34
|
+
{ label: "Due on receipt", value: "ngay" },
|
|
35
|
+
{ label: "Net 15", value: "15" },
|
|
36
|
+
{ label: "Net 30", value: "30" },
|
|
37
|
+
{ label: "Net 45", value: "45" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Grid cells: half-width on wide screens, full row when they can't fit. */
|
|
41
|
+
const half = { flexGrow: 1, flexBasis: 280 } as const;
|
|
42
|
+
const full = { flexGrow: 1, flexBasis: "100%" } as const;
|
|
43
|
+
|
|
44
|
+
function FieldsetTitle({ title, hint }: { title: string; hint: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<View style={{ gap: 2, paddingBottom: 14 }}>
|
|
47
|
+
<Text size="xs" color="muted" transform="uppercase">{title}</Text>
|
|
48
|
+
<Text size="xs" color="muted">{hint}</Text>
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TplIntake() {
|
|
54
|
+
const [tenCongTy, setTenCongTy] = useState("Northwind Packaging Co., Ltd.");
|
|
55
|
+
const [mst, setMst] = useState("031245678");
|
|
56
|
+
const [linhVuc, setLinhVuc] = useState("");
|
|
57
|
+
const [nguoiLienHe, setNguoiLienHe] = useState("Maria Lopez");
|
|
58
|
+
const [dienThoai, setDienThoai] = useState("0903 558 214");
|
|
59
|
+
const [email, setEmail] = useState("maria.lopez@northwindpkg.com");
|
|
60
|
+
const [nhomZalo, setNhomZalo] = useState("");
|
|
61
|
+
const [hanThanhToan, setHanThanhToan] = useState("30");
|
|
62
|
+
const [hoaDonVat, setHoaDonVat] = useState(true);
|
|
63
|
+
const [ghiChu, setGhiChu] = useState("");
|
|
64
|
+
const [giayTo, setGiayTo] = useState<DisplayFile[]>([]);
|
|
65
|
+
|
|
66
|
+
const soChuSo = mst.replace(/\D/g, "").length;
|
|
67
|
+
const mstError =
|
|
68
|
+
mst.length > 0 && !/^\d{10}(\d{3})?$/.test(mst.replace(/[\s.-]/g, ""))
|
|
69
|
+
? `Tax ID must be 10 or 13 digits — currently ${soChuSo}.`
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
74
|
+
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
75
|
+
{/* header band */}
|
|
76
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
77
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
78
|
+
<Text size="xl" weight="semibold">Client intake</Text>
|
|
79
|
+
<Text size="sm" color="muted">Set up a new partner record — complete details up front mean quotes, orders, and invoices never need a follow-up question</Text>
|
|
80
|
+
</View>
|
|
81
|
+
</View>
|
|
82
|
+
|
|
83
|
+
<Card style={{ padding: 0 }}>
|
|
84
|
+
{/* card header: what this form decides */}
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<CardHeaderTitle description="Enter once, reuse on every future quote and invoice — the tax ID is the matching key, double-check it before creating">
|
|
87
|
+
Partner record
|
|
88
|
+
</CardHeaderTitle>
|
|
89
|
+
</CardHeader>
|
|
90
|
+
|
|
91
|
+
{/* fieldset 1 · General information */}
|
|
92
|
+
<View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 4 }}>
|
|
93
|
+
<FieldsetTitle title="General information" hint="Legal name as on the business registration, not the trading name" />
|
|
94
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
|
|
95
|
+
<FormField label="Company name" style={full}>
|
|
96
|
+
<TextInputField value={tenCongTy} onChangeText={setTenCongTy} placeholder="Name on the business registration" />
|
|
97
|
+
</FormField>
|
|
98
|
+
<FormField label="Tax ID" error={mstError} style={half}>
|
|
99
|
+
<TextInputField
|
|
100
|
+
value={mst}
|
|
101
|
+
onChangeText={setMst}
|
|
102
|
+
placeholder="10 or 13 digits"
|
|
103
|
+
inputMode="numeric"
|
|
104
|
+
style={{ fontVariant: ["tabular-nums"] }}
|
|
105
|
+
/>
|
|
106
|
+
</FormField>
|
|
107
|
+
<FormField label="Industry" style={half}>
|
|
108
|
+
<Picker options={INDUSTRIES} value={linhVuc} onValueChange={setLinhVuc} placeholder="Select industry" />
|
|
109
|
+
</FormField>
|
|
110
|
+
</View>
|
|
111
|
+
</View>
|
|
112
|
+
<Divider />
|
|
113
|
+
|
|
114
|
+
{/* fieldset 2 · Contact */}
|
|
115
|
+
<View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 4 }}>
|
|
116
|
+
<FieldsetTitle title="Contact" hint="The person who receives quotes and the monthly statement reconciliation" />
|
|
117
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
|
|
118
|
+
<FormField label="Contact person" style={half}>
|
|
119
|
+
<TextInputField value={nguoiLienHe} onChangeText={setNguoiLienHe} placeholder="Full name" />
|
|
120
|
+
</FormField>
|
|
121
|
+
<FormField label="Phone" style={half}>
|
|
122
|
+
<TextInputField
|
|
123
|
+
value={dienThoai}
|
|
124
|
+
onChangeText={setDienThoai}
|
|
125
|
+
placeholder="Mobile number"
|
|
126
|
+
inputMode="tel"
|
|
127
|
+
style={{ fontVariant: ["tabular-nums"] }}
|
|
128
|
+
/>
|
|
129
|
+
</FormField>
|
|
130
|
+
<FormField label="Email" style={half}>
|
|
131
|
+
<TextInputField value={email} onChangeText={setEmail} placeholder="name@company.com" inputMode="email" autoCapitalize="none" />
|
|
132
|
+
</FormField>
|
|
133
|
+
<FormField label="Chat group" optional optionalLabel="Optional" style={half}>
|
|
134
|
+
<TextInputField value={nhomZalo} onChangeText={setNhomZalo} placeholder="Group used to coordinate orders" />
|
|
135
|
+
</FormField>
|
|
136
|
+
</View>
|
|
137
|
+
</View>
|
|
138
|
+
<Divider />
|
|
139
|
+
|
|
140
|
+
{/* fieldset 3 · Terms */}
|
|
141
|
+
<View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 4 }}>
|
|
142
|
+
<FieldsetTitle title="Terms" hint="Applied as the default on every order for this partner, adjustable per order" />
|
|
143
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
|
|
144
|
+
<FormField label="Payment terms" style={half}>
|
|
145
|
+
<Picker options={PAYMENT_TERMS} value={hanThanhToan} onValueChange={setHanThanhToan} placeholder="Select payment terms" />
|
|
146
|
+
</FormField>
|
|
147
|
+
<FormField label="VAT invoice" style={half}>
|
|
148
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 40 }}>
|
|
149
|
+
<Switch accessibilityLabel="VAT invoice" value={hoaDonVat} onChange={setHoaDonVat} />
|
|
150
|
+
<Text size="sm" color="muted">{hoaDonVat ? "Issue an invoice for every order" : "No invoices issued"}</Text>
|
|
151
|
+
</View>
|
|
152
|
+
</FormField>
|
|
153
|
+
<FormField label="Notes" optional optionalLabel="Optional" style={full}>
|
|
154
|
+
<TextInputField
|
|
155
|
+
value={ghiChu}
|
|
156
|
+
onChangeText={setGhiChu}
|
|
157
|
+
placeholder="Special requirements for delivery, documents, reconciliation…"
|
|
158
|
+
numberOfLines={3}
|
|
159
|
+
/>
|
|
160
|
+
</FormField>
|
|
161
|
+
</View>
|
|
162
|
+
</View>
|
|
163
|
+
|
|
164
|
+
<Divider />
|
|
165
|
+
|
|
166
|
+
{/* fieldset 4 · Documents — the attachment pattern: FileDropzone
|
|
167
|
+
captures, FileThumbnailGrid displays what landed */}
|
|
168
|
+
<View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 16, gap: 12 }}>
|
|
169
|
+
<FieldsetTitle title="Documents" hint="Business registration and legal documents — clear scans or photos" />
|
|
170
|
+
<FileDropzone
|
|
171
|
+
height={120}
|
|
172
|
+
label="Drop the business registration here"
|
|
173
|
+
hint="or click to browse · PDF, images"
|
|
174
|
+
dropLabel="Release to attach"
|
|
175
|
+
accept="application/pdf,image/*"
|
|
176
|
+
accessibilityLabel="Attach business registration"
|
|
177
|
+
onFiles={(files) =>
|
|
178
|
+
setGiayTo((prev) => [
|
|
179
|
+
...prev,
|
|
180
|
+
...files.map((f, i) => ({
|
|
181
|
+
id: `${f.name}-${prev.length + i}`,
|
|
182
|
+
filename: f.name,
|
|
183
|
+
mimeType: f.type || "application/octet-stream",
|
|
184
|
+
url: URL.createObjectURL(f),
|
|
185
|
+
})),
|
|
186
|
+
])
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
{giayTo.length > 0 ? <FileThumbnailGrid files={giayTo} itemSize={88} /> : null}
|
|
190
|
+
</View>
|
|
191
|
+
|
|
192
|
+
{/* footer per Action Layout: helper left · Cancel muted + ONE primary right */}
|
|
193
|
+
<CardFooter>
|
|
194
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>
|
|
195
|
+
{giayTo.length > 0
|
|
196
|
+
? `${giayTo.length} files attached · record awaits Accounting approval before the first order`
|
|
197
|
+
: "New records await Accounting approval before the first order"}
|
|
198
|
+
</Text>
|
|
199
|
+
<Button title="Cancel" color="muted" onPress={() => {}} />
|
|
200
|
+
<Button title="Create record" color="primary" onPress={() => {}} />
|
|
201
|
+
</CardFooter>
|
|
202
|
+
</Card>
|
|
203
|
+
</View>
|
|
204
|
+
</ScrollView>
|
|
205
|
+
);
|
|
206
|
+
}
|