@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.
Files changed (55) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,554 @@
1
+ import { useState } from "react";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors, ramp, solid, tint } from "@lotics/ui/colors";
5
+ import {
6
+ Accordion,
7
+ AccordionContent,
8
+ AccordionHeader,
9
+ AccordionMeta,
10
+ AccordionTitle,
11
+ } from "@lotics/ui/accordion";
12
+ import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
13
+ import { Avatar } from "@lotics/ui/avatar";
14
+ import { Badge } from "@lotics/ui/badge";
15
+ import { BarChart } from "@lotics/ui/bar_chart";
16
+ import { LineChart } from "@lotics/ui/line_chart";
17
+ import { PieChart } from "@lotics/ui/pie_chart";
18
+ import { RingGauge } from "@lotics/ui/ring_gauge";
19
+ import { Sparkline } from "@lotics/ui/sparkline";
20
+ import { TrendFooter } from "@lotics/ui/trend_footer";
21
+ import { Button } from "@lotics/ui/button";
22
+ import { Card, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
23
+ import {
24
+ DateRangeFilterField,
25
+ type DateRangeFilterFieldLabels,
26
+ } from "@lotics/ui/date_range_filter_field";
27
+ import { type DateFilterValue } from "@lotics/ui/date_filter";
28
+ import { Divider } from "@lotics/ui/divider";
29
+ import { Drawer } from "@lotics/ui/drawer";
30
+ import { PressableRow } from "@lotics/ui/pressable_row";
31
+ import { type IconName } from "@lotics/ui/icon";
32
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
33
+ import { Peek } from "@lotics/ui/peek";
34
+ import { StackedProgressBar } from "@lotics/ui/stacked_progress_bar";
35
+ import { formatMoney } from "@lotics/ui/format_money";
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Template · Executive dashboard — the executive operations dashboard. One
39
+ // screen that answers "how is this month going, where is it stuck, who pays
40
+ // the bills".
41
+ //
42
+ // Bands: header + period badge · KPI strip (KPIStrip) · revenue by
43
+ // month (BarChart, emerald = money) next to the sales pipeline
44
+ // (StackedProgressBar, blue = pipeline) · Needs attention (Accordion rows —
45
+ // icon + label + count Badge; expanding opens the records behind the
46
+ // count, each record carries its domain ⋯ ActionMenu) · Top customers
47
+ // (Avatar + revenue + margin badge; the name Peeks the dossier).
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ const DOANH_THU_THANG = [
51
+ { label: "Jan", month: "2026-01", value: 842 },
52
+ { label: "Feb", month: "2026-02", value: 918 },
53
+ { label: "Mar", month: "2026-03", value: 1054 },
54
+ { label: "Apr", month: "2026-04", value: 986 },
55
+ { label: "May", month: "2026-05", value: 1152 },
56
+ { label: "Jun", month: "2026-06", value: 1284 },
57
+ ];
58
+
59
+ // Per-period KPI datasets — the date filter actually filters. Custom ranges
60
+ // outside the mock months keep the latest dataset (mock limitation).
61
+ const KY_DU_LIEU: Record<
62
+ string,
63
+ {
64
+ doanhThu: number;
65
+ doanhThuTrend: number;
66
+ doanhThuCaption: string;
67
+ bienLai: number;
68
+ bienLaiTrend: number;
69
+ bienLaiCaption: string;
70
+ donDangXuLy: number;
71
+ donCaption: string;
72
+ congNo: number;
73
+ congNoCaption: string;
74
+ }
75
+ > = {
76
+ "2026-04": {
77
+ doanhThu: 986_000_000, doanhThuTrend: -6, doanhThuCaption: "Mar: ₫1.05B",
78
+ bienLai: 16.8, bienLaiTrend: -1, bienLaiCaption: "Mar: 17.4%",
79
+ donDangXuLy: 31, donCaption: "2 orders past delivery date",
80
+ congNo: 512_000_000, congNoCaption: "8 customers overdue · up 4%",
81
+ },
82
+ "2026-05": {
83
+ doanhThu: 1_152_000_000, doanhThuTrend: 17, doanhThuCaption: "Apr: ₫986M",
84
+ bienLai: 17.1, bienLaiTrend: 2, bienLaiCaption: "Apr: 16.8%",
85
+ donDangXuLy: 34, donCaption: "3 orders past delivery date",
86
+ congNo: 449_000_000, congNoCaption: "5 customers overdue · down 12%",
87
+ },
88
+ "2026-06": {
89
+ doanhThu: 1_284_000_000, doanhThuTrend: 11, doanhThuCaption: "May: ₫1.15B",
90
+ bienLai: 18.4, bienLaiTrend: 1, bienLaiCaption: "May: 17.1%",
91
+ donDangXuLy: 37, donCaption: "4 orders past delivery date",
92
+ congNo: 486_000_000, congNoCaption: "6 customers overdue · up 8%",
93
+ },
94
+ };
95
+ const KY_CUOI = "2026-06";
96
+
97
+ const NHAN_BO_LOC: Partial<DateRangeFilterFieldLabels> = {
98
+ year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
99
+ today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow", thisWeek: "This week",
100
+ thisMonth: "This month", lastMonth: "Last month", custom: "Custom", from: "From", to: "To",
101
+ selectDateRange: "Select date range", selectDate: "Select date",
102
+ clear: "Clear", done: "Done", placeholder: "All time",
103
+ };
104
+
105
+ function thangRange(year: number, monthIndex: number): DateFilterValue {
106
+ return {
107
+ start: { date: new Date(year, monthIndex, 1), time: null },
108
+ end: { date: new Date(year, monthIndex + 1, 0), time: null },
109
+ };
110
+ }
111
+
112
+ /** "YYYY-MM" when the range is exactly one calendar month, else null. */
113
+ function thangKey(value: DateFilterValue): string | null {
114
+ const s = value.start.date;
115
+ const e = value.end.date;
116
+ if (!s || !e) return null;
117
+ const wholeMonth =
118
+ s.getDate() === 1 &&
119
+ s.getMonth() === e.getMonth() &&
120
+ s.getFullYear() === e.getFullYear() &&
121
+ e.getDate() === new Date(e.getFullYear(), e.getMonth() + 1, 0).getDate();
122
+ return wholeMonth ? `${s.getFullYear()}-${String(s.getMonth() + 1).padStart(2, "0")}` : null;
123
+ }
124
+
125
+ const PHEU_STAGES = [
126
+ { key: "tiepcan", label: "Prospecting", count: 46 },
127
+ { key: "baogia", label: "Quote sent", count: 28 },
128
+ { key: "damphan", label: "Negotiation", count: 14 },
129
+ { key: "chot", label: "Closed won", count: 9 },
130
+ ];
131
+ // One hue, shaded along the funnel — closed-won (the win) is the strongest.
132
+ const PHEU_RAMP = ramp("blue", PHEU_STAGES.length).reverse();
133
+ const PHEU = PHEU_STAGES.map((p, i) => ({ ...p, color: PHEU_RAMP[i] }));
134
+
135
+ // Margin per month — the LineChart series.
136
+ const BIEN_LAI_THANG = [
137
+ { x: "Jan", y: 15.9 }, { x: "Feb", y: 16.4 }, { x: "Mar", y: 17.4 },
138
+ { x: "Apr", y: 16.8 }, { x: "May", y: 17.1 }, { x: "Jun", y: 18.4 },
139
+ ];
140
+
141
+ // Revenue mix by customer industry — the PieChart slices.
142
+ const CO_CAU = [
143
+ { label: "Machining", value: 392, color: solid("blue") },
144
+ { label: "Furniture", value: 286, color: solid("emerald") },
145
+ { label: "Plastics", value: 248, color: solid("amber") },
146
+ { label: "Other", value: 358, color: solid("zinc") },
147
+ ];
148
+
149
+ const KHACH_DAN_DAU = [
150
+ { ten: "Atlas Components", nganh: "Export machining", doanhThu: 312_400_000, bienLai: 22, trend: [38, 44, 51, 47, 56, 62] },
151
+ { ten: "Crestline Plastics", nganh: "Household plastics", doanhThu: 248_900_000, bienLai: 19, trend: [42, 39, 45, 41, 44, 43] },
152
+ { ten: "Brightline Batteries", nganh: "Electrical — batteries", doanhThu: 186_200_000, bienLai: 11, trend: [40, 36, 33, 31, 28, 26] },
153
+ { ten: "Vittoria Accessories", nganh: "Bicycle accessories", doanhThu: 154_700_000, bienLai: 24, trend: [18, 22, 25, 24, 29, 33] },
154
+ { ten: "Northwind Furniture", nganh: "Export furniture", doanhThu: 121_500_000, bienLai: 9, trend: [25, 23, 20, 22, 19, 17] },
155
+ ];
156
+
157
+ // Each alert carries the records behind its count — pressing the row opens
158
+ // them inline. The count alone says "6 overdue"; the drill-down answers
159
+ // "which 6, how bad, what do I open next". Every expanded record gets the
160
+ // alert's domain actions via its ⋯ menu — no dead rows.
161
+ interface AlertDetail {
162
+ key: string;
163
+ icon: IconName;
164
+ iconColor: string;
165
+ label: string;
166
+ count: number;
167
+ hint: string;
168
+ tone?: "warning";
169
+ records: { id: string; name: string; value: string; note: string; noteTone: "danger" | "muted" }[];
170
+ rowActions: Omit<ActionMenuItem, "onPress">[];
171
+ action: string;
172
+ }
173
+
174
+ const CAN_CHU_Y: AlertDetail[] = [
175
+ {
176
+ key: "congno",
177
+ icon: "circle-alert",
178
+ iconColor: solid("red"),
179
+ label: "Receivables overdue past 30 days",
180
+ count: 6,
181
+ hint: formatMoney(286_500_000),
182
+ records: [
183
+ { id: "INV-2026-0231", name: "Brightline Batteries", value: formatMoney(86_400_000), note: "42 days late", noteTone: "danger" },
184
+ { id: "INV-2026-0246", name: "Northwind Furniture", value: formatMoney(64_200_000), note: "35 days late", noteTone: "danger" },
185
+ { id: "INV-2026-0252", name: "Crestline Plastics", value: formatMoney(52_800_000), note: "31 days late", noteTone: "danger" },
186
+ ],
187
+ rowActions: [
188
+ { key: "nhac", label: "Send payment reminder", icon: "bell" },
189
+ { key: "doichieu", label: "Send statement", icon: "mail" },
190
+ { key: "mo", label: "Open invoice", icon: "external-link" },
191
+ ],
192
+ action: "Open Receivables",
193
+ },
194
+ {
195
+ key: "trethoigiao",
196
+ icon: "package",
197
+ iconColor: solid("red"),
198
+ label: "Orders past delivery date",
199
+ count: 4,
200
+ hint: "Earliest overdue since 04/06",
201
+ records: [
202
+ { id: "SO-2026-0418", name: "Atlas Components", value: "Due 04/06", note: "8 days late", noteTone: "danger" },
203
+ { id: "SO-2026-0431", name: "Vittoria Accessories", value: "Due 06/06", note: "6 days late", noteTone: "danger" },
204
+ { id: "SO-2026-0436", name: "Crestline Plastics", value: "Due 09/06", note: "3 days late", noteTone: "danger" },
205
+ ],
206
+ rowActions: [
207
+ { key: "baokhach", label: "Notify customer", icon: "mail" },
208
+ { key: "doingay", label: "Reschedule delivery", icon: "calendar" },
209
+ { key: "mo", label: "Open order", icon: "external-link" },
210
+ ],
211
+ action: "Open Orders",
212
+ },
213
+ {
214
+ key: "ketsx",
215
+ icon: "clock",
216
+ iconColor: solid("amber"),
217
+ label: "Work orders stuck over 2 days",
218
+ count: 2,
219
+ tone: "warning",
220
+ hint: "Printing stage",
221
+ records: [
222
+ { id: "WO-2026-0102", name: "Atlas 5-ply box", value: "Printing stage", note: "Stuck 4 days", noteTone: "danger" },
223
+ { id: "WO-2026-0107", name: "Crestline A3 carton", value: "Printing stage", note: "Stuck 3 days", noteTone: "muted" },
224
+ ],
225
+ rowActions: [
226
+ { key: "nhacto", label: "Notify Printing team", icon: "bell" },
227
+ { key: "mo", label: "Open work order", icon: "external-link" },
228
+ ],
229
+ action: "Open Production",
230
+ },
231
+ {
232
+ key: "baogiacho",
233
+ icon: "file-text",
234
+ iconColor: solid("amber"),
235
+ label: "Quotes awaiting approval",
236
+ count: 3,
237
+ tone: "warning",
238
+ hint: "Send by 13/06",
239
+ records: [
240
+ { id: "QT-2026-0089", name: "Atlas Components", value: formatMoney(148_000_000), note: "Waiting 2 days", noteTone: "muted" },
241
+ { id: "QT-2026-0091", name: "Eastgate Trading Co.", value: formatMoney(96_500_000), note: "Waiting 1 day", noteTone: "muted" },
242
+ { id: "QT-2026-0092", name: "Crestline Plastics", value: formatMoney(54_200_000), note: "Today", noteTone: "muted" },
243
+ ],
244
+ rowActions: [
245
+ { key: "duyet", label: "Approve quote", icon: "check" },
246
+ { key: "mo", label: "Open quote", icon: "external-link" },
247
+ { key: "tuchoi", label: "Reject", icon: "x", danger: true },
248
+ ],
249
+ action: "Open Quotes",
250
+ },
251
+ ];
252
+
253
+ // Customer dossier behind the leaderboard name — context glance, the row's
254
+ // job is reading, not working the record.
255
+ function KhachPeek({ kh }: { kh: (typeof KHACH_DAN_DAU)[number] }) {
256
+ return (
257
+ <View style={{ gap: 10 }}>
258
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
259
+ <Avatar name={kh.ten} size={32} />
260
+ <View style={{ gap: 1, flex: 1 }}>
261
+ <Text size="sm" weight="semibold">{kh.ten}</Text>
262
+ <Text size="xs" color="muted">{kh.nganh}</Text>
263
+ </View>
264
+ </View>
265
+ <Divider />
266
+ {([
267
+ ["6-month revenue", formatMoney(kh.doanhThu)],
268
+ ["Margin", `${kh.bienLai}%`],
269
+ ] as const).map(([label, value]) => (
270
+ <View key={label} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
271
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
272
+ <Text size="sm" tabular>{value}</Text>
273
+ </View>
274
+ ))}
275
+ <Divider />
276
+ <Button title="Open customer" color="secondary" onPress={() => {}} />
277
+ </View>
278
+ );
279
+ }
280
+
281
+ function LegendRow({ stage, total }: { stage: (typeof PHEU)[number]; total: number }) {
282
+ const pct = Math.round((stage.count / total) * 100);
283
+ return (
284
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
285
+ <View style={{ width: 8, height: 8, borderRadius: 999, backgroundColor: stage.color }} />
286
+ <Text size="sm" style={{ flex: 1 }}>{stage.label}</Text>
287
+ <Text size="sm" weight="medium" tabular>{stage.count}</Text>
288
+ <Text size="xs" color="muted" tabular style={{ width: 36 }} align="right">
289
+ {pct}%
290
+ </Text>
291
+ </View>
292
+ );
293
+ }
294
+
295
+ export function TplDashboard() {
296
+ const tongPheu = PHEU.reduce((sum, s) => sum + s.count, 0);
297
+ const [openAlert, setOpenAlert] = useState<string | null>(null);
298
+ // A pressed record opens its own drawer, sequenced within the alert's list.
299
+ const [openRec, setOpenRec] = useState<{ alertKey: string; idx: number } | null>(null);
300
+ const recAlert = openRec ? CAN_CHU_Y.find((a) => a.key === openRec.alertKey) ?? null : null;
301
+ const rec = recAlert ? recAlert.records[openRec!.idx] : null;
302
+ const [period, setPeriod] = useState<DateFilterValue>(() => thangRange(2026, 5));
303
+
304
+ const key = thangKey(period);
305
+ const ky = (key ? KY_DU_LIEU[key] : undefined) ?? KY_DU_LIEU[KY_CUOI];
306
+
307
+ return (
308
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
309
+ <View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
310
+ {/* header band — the period filter lives here: presets + custom
311
+ range in the popover (and time, via includeTime, where a
312
+ screen needs it) */}
313
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
314
+ <View style={{ gap: 2, flex: 1 }}>
315
+ <Text size="xl" weight="semibold">Executive dashboard</Text>
316
+ <Text size="sm" color="muted">Revenue, the sales pipeline and what needs handling — refreshed every morning</Text>
317
+ </View>
318
+ <DateRangeFilterField value={period} onValueChange={setPeriod} locale="en-GB" labels={NHAN_BO_LOC} />
319
+ </View>
320
+
321
+ {/* KPI strip follows the selected period; Receivables/Orders drill into the alerts below */}
322
+ <KPIStrip
323
+ items={[
324
+ { label: "Revenue", value: ky.doanhThu, format: "currency", compact: true, trend: ky.doanhThuTrend, caption: ky.doanhThuCaption },
325
+ { label: "Margin", value: ky.bienLai, format: "percentage", trend: ky.bienLaiTrend, caption: ky.bienLaiCaption },
326
+ { label: "Orders in progress", value: ky.donDangXuLy, caption: ky.donCaption, info: "Orders past intake and not yet settled. Overdue ones are listed under Needs attention below." },
327
+ { label: "Receivables", value: ky.congNo, format: "currency", compact: true, tone: "warning", caption: ky.congNoCaption, info: "Unpaid invoices at the end of the period. The overdue list sits under Needs attention below." },
328
+ ]}
329
+ />
330
+
331
+ {/* chart row 1: revenue by month (BarChart + TrendFooter) · margin trend (LineChart) */}
332
+ <View style={{ flexDirection: "row", gap: 16, alignItems: "stretch" }}>
333
+ <Card style={{ padding: 0, flex: 3 }}>
334
+ <CardHeader>
335
+ <CardHeaderTitle info="Revenue recognized by invoice date, before discounts">
336
+ Revenue by month
337
+ </CardHeaderTitle>
338
+ <CardHeaderMeta>Unit: ₫ million</CardHeaderMeta>
339
+ </CardHeader>
340
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 12 }}>
341
+ {/* the filtered month pops; the rest recede to context */}
342
+ <BarChart
343
+ data={DOANH_THU_THANG.map((m) => ({
344
+ ...m,
345
+ color: key === null || m.month === key ? solid("emerald") : tint("emerald", 0.4),
346
+ }))}
347
+ formatNumber={(n) => n.toLocaleString("en-GB")}
348
+ />
349
+ <TrendFooter value={11} periodLabel="vs last month" detail="May: ₫1.15B" />
350
+ </View>
351
+ </Card>
352
+
353
+ <Card style={{ padding: 0, flex: 2 }}>
354
+ <CardHeader>
355
+ <CardHeaderTitle info="Gross margin per month — revenue minus snapshotted cost of goods">
356
+ Margin trend
357
+ </CardHeaderTitle>
358
+ <CardHeaderMeta>%</CardHeaderMeta>
359
+ </CardHeader>
360
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, flex: 1, justifyContent: "center" }}>
361
+ <LineChart points={BIEN_LAI_THANG} height={150} lineColor={solid("blue")} formatNumber={(n) => `${n}%`} />
362
+ </View>
363
+ </Card>
364
+ </View>
365
+
366
+ {/* chart row 2: pipeline funnel · revenue mix · on-time delivery */}
367
+ <View style={{ flexDirection: "row", gap: 16, alignItems: "stretch" }}>
368
+ <Card style={{ padding: 0, flex: 3 }}>
369
+ <CardHeader>
370
+ <CardHeaderTitle info="Open opportunities at each stage, updated from Quotes">
371
+ Sales pipeline
372
+ </CardHeaderTitle>
373
+ <CardHeaderMeta>{`${tongPheu} opportunities`}</CardHeaderMeta>
374
+ </CardHeader>
375
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 14, flex: 1 }}>
376
+ <StackedProgressBar
377
+ segments={PHEU.map((s) => ({ key: s.key, value: s.count, color: s.color }))}
378
+ total={tongPheu}
379
+ />
380
+ <View style={{ gap: 10 }}>
381
+ {PHEU.map((s) => <LegendRow key={s.key} stage={s} total={tongPheu} />)}
382
+ </View>
383
+ <View style={{ flex: 1 }} />
384
+ <Text size="xs" color="muted">Quote-to-close rate: 32% — up on last quarter</Text>
385
+ </View>
386
+ </Card>
387
+
388
+ <Card style={{ padding: 0, flex: 3 }}>
389
+ <CardHeader>
390
+ <CardHeaderTitle info="Period revenue split by customer industry">
391
+ Revenue mix
392
+ </CardHeaderTitle>
393
+ <CardHeaderMeta>₫ million</CardHeaderMeta>
394
+ </CardHeader>
395
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, flex: 1, justifyContent: "center" }}>
396
+ <PieChart slices={CO_CAU} size={140} showLegend formatNumber={(n) => n.toLocaleString("en-GB")} />
397
+ </View>
398
+ </Card>
399
+
400
+ <Card style={{ padding: 0, flex: 2 }}>
401
+ <CardHeader>
402
+ <CardHeaderTitle info="Deliveries that arrived on or before the needed-by date this period">
403
+ On-time delivery
404
+ </CardHeaderTitle>
405
+ </CardHeader>
406
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16, flex: 1, alignItems: "center", justifyContent: "center" }}>
407
+ <RingGauge value={87} label="On time" caption="33 of 38 deliveries" color={solid("emerald")} />
408
+ </View>
409
+ </Card>
410
+ </View>
411
+
412
+ {/* needs attention — each row expands in place to the records behind its count */}
413
+ <Card style={{ padding: 0 }}>
414
+ <CardHeader>
415
+ <CardHeaderTitle info="Backlog past its threshold, worst first — press a row to see the records behind the count">
416
+ Needs attention
417
+ </CardHeaderTitle>
418
+ </CardHeader>
419
+ <View style={{ paddingHorizontal: 20, paddingVertical: 6 }}>
420
+ {CAN_CHU_Y.map((a) => (
421
+ <Accordion
422
+ key={a.key}
423
+ expanded={openAlert === a.key}
424
+ onToggle={() => setOpenAlert(openAlert === a.key ? null : a.key)}
425
+ >
426
+ <AccordionHeader accessibilityLabel={a.label}>
427
+ <AccordionTitle icon={a.icon} iconColor={a.iconColor}>{a.label}</AccordionTitle>
428
+ <AccordionMeta>{a.hint}</AccordionMeta>
429
+ <Badge label={String(a.count)} color={a.tone === "warning" ? "amber" : "red"} />
430
+ </AccordionHeader>
431
+ <AccordionContent>
432
+ {/* drill-down sub-rows: inset PressableRow so the wash spans
433
+ the whole row incl. the ⋯; no dividers — the rounded
434
+ hover separates them. Press = workspace, ⋯ = quick ops. */}
435
+ {a.records.map((r, j) => (
436
+ <PressableRow
437
+ key={r.id}
438
+ variant="inset"
439
+ onPress={() => setOpenRec({ alertKey: a.key, idx: j })}
440
+ selected={openRec?.alertKey === a.key && openRec?.idx === j}
441
+ style={{ gap: 8, minHeight: 40 }}
442
+ >
443
+ <Pressable
444
+ accessibilityRole="button"
445
+ accessibilityLabel={`Open ${r.id}`}
446
+ onPress={() => setOpenRec({ alertKey: a.key, idx: j })}
447
+ style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 16 }}
448
+ >
449
+ <Text size="sm" weight="medium" tabular style={{ width: 124 }}>{r.id}</Text>
450
+ <Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{r.name}</Text>
451
+ <Text size="sm" tabular>{r.value}</Text>
452
+ <Text size="xs" color={r.noteTone} tabular style={{ width: 88 }} align="right">{r.note}</Text>
453
+ </Pressable>
454
+ <ActionMenu
455
+ accessibilityLabel={`Actions for ${r.id}`}
456
+ items={a.rowActions.map((act) => ({ ...act, onPress: () => {} }))}
457
+ />
458
+ </PressableRow>
459
+ ))}
460
+ <View style={{ paddingTop: 8, paddingBottom: 2, flexDirection: "row", alignItems: "center", gap: 12 }}>
461
+ <Text size="xs" color="muted" style={{ flex: 1 }}>
462
+ {a.count > a.records.length
463
+ ? `Showing ${a.records.length} of ${a.count} — open the list to see all`
464
+ : "Full list"}
465
+ </Text>
466
+ <Button title={a.action} color="secondary" onPress={() => {}} />
467
+ </View>
468
+ </AccordionContent>
469
+ </Accordion>
470
+ ))}
471
+ </View>
472
+ </Card>
473
+
474
+ {/* top customers */}
475
+ <Card style={{ padding: 0 }}>
476
+ <CardHeader>
477
+ <CardHeaderTitle info="Top customers by trailing 6-month revenue, with each customer's margin">
478
+ Top customers
479
+ </CardHeaderTitle>
480
+ <CardHeaderMeta>Trailing 6-month revenue</CardHeaderMeta>
481
+ </CardHeader>
482
+ {KHACH_DAN_DAU.map((kh, i) => (
483
+ <View key={kh.ten}>
484
+ {i > 0 ? <Divider /> : null}
485
+ <View style={{ paddingHorizontal: 20, paddingVertical: 12, flexDirection: "row", alignItems: "center", gap: 12 }}>
486
+ <Avatar name={kh.ten} size={32} />
487
+ <View style={{ flex: 1, gap: 0, alignItems: "flex-start" }}>
488
+ <Peek accessibilityLabel={`Customer profile for ${kh.ten}`} content={<KhachPeek kh={kh} />}>
489
+ <Text size="sm" weight="medium" numberOfLines={1}>{kh.ten}</Text>
490
+ </Peek>
491
+ <Text size="xs" color="muted" numberOfLines={1}>{kh.nganh}</Text>
492
+ </View>
493
+ <Sparkline data={kh.trend} width={84} height={24} color={kh.trend[5] >= kh.trend[0] ? solid("emerald") : solid("red")} />
494
+ <Text size="sm" weight="medium" tabular>{formatMoney(kh.doanhThu)}</Text>
495
+ {/* margin is a METRIC, not a status — the dot variant signals
496
+ its quality (healthy ≥15% vs thin) without a heavy chip */}
497
+ <View style={{ width: 104, alignItems: "flex-end" }}>
498
+ <Badge variant="dot" label={`${kh.bienLai}% margin`} color={kh.bienLai >= 15 ? "emerald" : "amber"} />
499
+ </View>
500
+ </View>
501
+ </View>
502
+ ))}
503
+ </Card>
504
+ </View>
505
+
506
+ {recAlert && rec && openRec ? (
507
+ <Drawer
508
+ open
509
+ onOpenChange={(open) => !open && setOpenRec(null)}
510
+ title={rec.id}
511
+ width={440}
512
+ onPrev={openRec.idx > 0 ? () => setOpenRec({ alertKey: openRec.alertKey, idx: openRec.idx - 1 }) : undefined}
513
+ onNext={openRec.idx < recAlert.records.length - 1 ? () => setOpenRec({ alertKey: openRec.alertKey, idx: openRec.idx + 1 }) : undefined}
514
+ position={`${openRec.idx + 1}/${recAlert.records.length}`}
515
+ >
516
+ <View key={rec.id} style={{ flex: 1, padding: 24, gap: 12 }}>
517
+ <Badge label={recAlert.label} color={recAlert.tone === "warning" ? "amber" : "red"} />
518
+ <View style={{ gap: 10, paddingTop: 4 }}>
519
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
520
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
521
+ <Text size="sm" weight="medium">{rec.name}</Text>
522
+ </View>
523
+ <Divider />
524
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
525
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Amount / due</Text>
526
+ <Text size="sm" tabular>{rec.value}</Text>
527
+ </View>
528
+ <Divider />
529
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
530
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Status</Text>
531
+ <Text size="sm" color={rec.noteTone} tabular>{rec.note}</Text>
532
+ </View>
533
+ </View>
534
+ </View>
535
+ <View
536
+ style={{
537
+ borderTopWidth: 1,
538
+ borderTopColor: colors.border,
539
+ paddingHorizontal: 20,
540
+ paddingVertical: 14,
541
+ flexDirection: "row",
542
+ alignItems: "center",
543
+ justifyContent: "flex-end",
544
+ gap: 12,
545
+ }}
546
+ >
547
+ <Button title={recAlert.rowActions[0]?.label ?? "Remind"} color="secondary" onPress={() => {}} />
548
+ <Button title="Open record" color="primary" onPress={() => {}} />
549
+ </View>
550
+ </Drawer>
551
+ ) : null}
552
+ </ScrollView>
553
+ );
554
+ }