@lotics/ui 7.7.0 → 7.9.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 (59) hide show
  1. package/AGENTS.md +75 -33
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record.tsx +1 -1
  26. package/examples/tpl_record_plain.tsx +1 -1
  27. package/examples/tpl_report.tsx +1 -1
  28. package/examples/tpl_rollup.tsx +1 -1
  29. package/examples/tpl_settings.tsx +1 -1
  30. package/examples/tpl_shifts.tsx +1 -1
  31. package/examples/tpl_stock.tsx +1 -1
  32. package/examples/tpl_task_board.tsx +2 -2
  33. package/examples/tpl_tasks.tsx +1 -1
  34. package/examples/tpl_timeline.tsx +1 -1
  35. package/examples/tpl_tower.tsx +1 -1
  36. package/examples/tpl_triage.tsx +1 -1
  37. package/examples/tpl_wizard.tsx +1 -1
  38. package/package.json +3 -1
  39. package/src/card.tsx +6 -13
  40. package/src/combobox.tsx +28 -11
  41. package/src/data_grid.tsx +1 -1
  42. package/src/kpi_strip.tsx +5 -5
  43. package/src/pressable_row.tsx +22 -10
  44. package/src/sort_header.tsx +9 -4
  45. package/src/summary_line.tsx +76 -0
  46. package/src/table.tsx +27 -11
  47. package/src/use_selection.test.ts +66 -0
  48. package/src/use_selection.ts +75 -0
  49. package/examples/app_orders.tsx +0 -405
  50. package/examples/tpl_approvals.tsx +0 -375
  51. package/examples/tpl_batch.tsx +0 -234
  52. package/examples/tpl_callsheet.tsx +0 -481
  53. package/examples/tpl_convert.tsx +0 -478
  54. package/examples/tpl_crm_desk.tsx +0 -541
  55. package/examples/tpl_dispatch.tsx +0 -289
  56. package/examples/tpl_inventory.tsx +0 -299
  57. package/examples/tpl_pipeline.tsx +0 -400
  58. package/examples/tpl_reconcile.tsx +0 -275
  59. package/examples/tpl_run.tsx +0 -235
@@ -1,289 +0,0 @@
1
- import { useState } from "react";
2
- import { Pressable, ScrollView, View } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { colors, solid } from "@lotics/ui/colors";
5
- import { ActionMenu } from "@lotics/ui/action_menu";
6
- import { Avatar } from "@lotics/ui/avatar";
7
- import { Badge } from "@lotics/ui/badge";
8
- import { Button } from "@lotics/ui/button";
9
- import { Card, CardFooter, CardHeader, CardHeaderTitle } 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 { KPIStrip } from "@lotics/ui/kpi_strip";
15
- import { MenuListItem } from "@lotics/ui/menu_list_item";
16
- import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
17
- import { Peek } from "@lotics/ui/peek";
18
- import { PressableRow } from "@lotics/ui/pressable_row";
19
- import { ProgressBar } from "@lotics/ui/progress_bar";
20
-
21
- // ─────────────────────────────────────────────────────────────────────────────
22
- // Template · Dispatch board — the work is ALLOCATION: fit a pool of demand
23
- // into limited capacity (deliveries → trucks; same shape for jobs →
24
- // machines, visits → technicians, bookings → rooms). The pool sits left;
25
- // each resource is a card whose load meter answers "does it fit?" before
26
- // you ask. Assigning is a popover that names the remaining capacity per
27
- // option and disables what doesn't fit — the gate is the data. Rows open
28
- // the same stop drawer from either side; assignment moves rows live.
29
- // ─────────────────────────────────────────────────────────────────────────────
30
-
31
- interface Stop {
32
- id: string;
33
- customer: string;
34
- destination: string;
35
- volume: number; // m³
36
- due: string;
37
- truck: string | null;
38
- }
39
-
40
- interface Truck {
41
- id: string;
42
- driver: string;
43
- capacity: number; // m³
44
- phone: string;
45
- licence: string;
46
- }
47
-
48
- const TRUCKS: Truck[] = [
49
- { id: "VL-01", driver: "Tom Becker", capacity: 12, phone: "+1 415 555 0142", licence: "Class C · exp 2027-03" },
50
- { id: "VL-02", driver: "Ray Santos", capacity: 12, phone: "+1 415 555 0188", licence: "Class C · exp 2026-11" },
51
- { id: "VL-03", driver: "Ken Adachi", capacity: 8, phone: "+1 415 555 0107", licence: "Class C · exp 2028-01" },
52
- ];
53
-
54
- const STOPS: Stop[] = [
55
- { id: "SR-2026-0027", customer: "BRIGHTCELL BATTERIES", destination: "Arden industrial park", volume: 4.2, due: "am", truck: "VL-01" },
56
- { id: "SR-2026-0031", customer: "ATLAS COMPONENTS", destination: "Eastport free zone", volume: 5.6, due: "am", truck: "VL-01" },
57
- { id: "SR-2026-0033", customer: "KING LUN PLASTICS", destination: "Brookfield district 4", volume: 6.1, due: "am", truck: "VL-02" },
58
- { id: "SR-2026-0035", customer: "VITTORIA ACCESSORIES", destination: "Centerville depot", volume: 3.4, due: "pm", truck: null },
59
- { id: "SR-2026-0036", customer: "CRESTLINE FURNITURE", destination: "Dalton port gate 2", volume: 7.8, due: "pm", truck: null },
60
- { id: "SR-2026-0038", customer: "MERIDIAN CONSTRUCTION", destination: "Fairview site B", volume: 2.2, due: "am", truck: null },
61
- { id: "SR-2026-0039", customer: "NORTHWIND PACKAGING", destination: "Hillcrest yard", volume: 4.9, due: "pm", truck: null },
62
- { id: "SR-2026-0040", customer: "BRIGHTCELL BATTERIES", destination: "Arden industrial park", volume: 1.6, due: "pm", truck: null },
63
- ];
64
-
65
- const fmt = (n: number) => `${n.toLocaleString("en-US", { maximumFractionDigits: 1 })} m³`;
66
-
67
- function StopRow({ s, openId, onOpen, right }: { s: Stop; openId: string | null; onOpen: () => void; right?: React.ReactNode }) {
68
- return (
69
- <PressableRow onPress={onOpen} selected={openId === s.id} style={{ gap: 8 }}>
70
- <Pressable
71
- accessibilityRole="button"
72
- accessibilityLabel={`Open stop ${s.id}`}
73
- onPress={onOpen}
74
- style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 52 }}
75
- >
76
- <View style={{ flex: 1, gap: 0 }}>
77
- <Text size="sm" numberOfLines={1}>{s.destination}</Text>
78
- <Text size="xs" color="muted" numberOfLines={1}>{`${s.id} · ${s.customer}`}</Text>
79
- </View>
80
- <Badge variant="dot" label={s.due === "am" ? "Morning" : "Afternoon"} color={s.due === "am" ? "blue" : "zinc"} />
81
- <Text size="sm" tabular align="right" style={{ width: 56 }}>{fmt(s.volume)}</Text>
82
- </Pressable>
83
- {right}
84
- </PressableRow>
85
- );
86
- }
87
-
88
- export function TplDispatch() {
89
- const [stops, setStops] = useState<Stop[]>(STOPS);
90
- const [openId, setOpenId] = useState<string | null>(null);
91
-
92
- const assign = (stopId: string, truckId: string | null) => {
93
- setStops((prev) => prev.map((s) => (s.id === stopId ? { ...s, truck: truckId } : s)));
94
- setOpenId(null);
95
- };
96
-
97
- const pool = stops.filter((s) => s.truck === null);
98
- const stopsOf = (truckId: string) => stops.filter((s) => s.truck === truckId);
99
- const loadOf = (truckId: string) => stopsOf(truckId).reduce((sum, s) => sum + s.volume, 0);
100
- const freeOf = (t: Truck) => t.capacity - loadOf(t.id);
101
-
102
- const totalCapacity = TRUCKS.reduce((s, t) => s + t.capacity, 0);
103
- const totalLoad = TRUCKS.reduce((s, t) => s + loadOf(t.id), 0);
104
- const poolVolume = pool.reduce((s, x) => s + x.volume, 0);
105
-
106
- // The drawer walks whichever list the stop was opened from.
107
- const open = stops.find((s) => s.id === openId) ?? null;
108
- const walk = open ? (open.truck === null ? pool : stopsOf(open.truck)) : [];
109
- const seqIndex = open ? walk.findIndex((s) => s.id === open.id) : -1;
110
-
111
- /** The fit-aware assign control — every option states its remaining
112
- * capacity; an option that can't take the stop is disabled, not hidden,
113
- * so "why can't I?" answers itself. */
114
- const AssignControl = ({ s }: { s: Stop }) => (
115
- <Popover side="bottom" align="end">
116
- <PopoverTrigger>
117
- <Button title="Assign" color="secondary" />
118
- </PopoverTrigger>
119
- <PopoverContent style={{ width: 320 }}>
120
- <View style={{ paddingHorizontal: 12, paddingTop: 10, paddingBottom: 6 }}>
121
- <Text size="xs" color="muted" transform="uppercase">{`${s.id} · ${fmt(s.volume)} → which truck?`}</Text>
122
- </View>
123
- <View style={{ paddingHorizontal: 6, paddingBottom: 8, gap: 2 }}>
124
- {TRUCKS.map((t) => {
125
- const fits = freeOf(t) >= s.volume;
126
- return (
127
- <MenuListItem
128
- key={t.id}
129
- title={`${t.id} · ${t.driver}`}
130
- description={fits ? `${fmt(freeOf(t))} free of ${fmt(t.capacity)}` : `won't fit — ${fmt(freeOf(t))} free`}
131
- disabled={!fits}
132
- onPress={() => assign(s.id, t.id)}
133
- />
134
- );
135
- })}
136
- </View>
137
- </PopoverContent>
138
- </Popover>
139
- );
140
-
141
- return (
142
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
143
- <View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
144
- {/* header band */}
145
- <View style={{ gap: 2 }}>
146
- <Text size="xl" weight="semibold">Dispatch board</Text>
147
- <Text size="sm" color="muted">Fit the day's demand into the fleet — assign until the pool is empty</Text>
148
- </View>
149
-
150
- <KPIStrip
151
- items={[
152
- { label: "Unassigned", value: pool.length, format: "number", tone: pool.length > 0 ? "warning" : "default", caption: `${fmt(poolVolume)} waiting` },
153
- { label: "Fleet load", value: Math.round((totalLoad / totalCapacity) * 100), format: "percentage", info: "Volume assigned across every truck vs total fleet capacity. The board's job is moving this toward 100% without breaking a single truck's limit." },
154
- { label: "Stops planned", value: stops.length - pool.length, format: "number", caption: `across ${TRUCKS.length} trucks` },
155
- { label: "Headroom", value: Math.round((totalCapacity - totalLoad) * 10) / 10, format: "number", caption: "m³ free fleet-wide" },
156
- ]}
157
- />
158
-
159
- <View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
160
- {/* the demand pool */}
161
- <Card style={{ padding: 0, flexGrow: 1, flexBasis: 400 }}>
162
- <CardHeader>
163
- <CardHeaderTitle info="Today's deliveries with no truck yet. Assign… names each truck's remaining capacity and disables what won't fit.">
164
- Unassigned
165
- </CardHeaderTitle>
166
- </CardHeader>
167
- {pool.length === 0 ? (
168
- <EmptyState message="Everything is on a truck" hint="The pool refills as new orders reach the delivery stage" />
169
- ) : (
170
- pool.map((s, i) => (
171
- <View key={s.id}>
172
- {i > 0 ? <Divider /> : null}
173
- <StopRow s={s} openId={openId} onOpen={() => setOpenId(s.id)} right={<AssignControl s={s} />} />
174
- </View>
175
- ))
176
- )}
177
- <CardFooter>
178
- <Text size="xs" color="muted" tabular style={{ flex: 1 }}>{`${pool.length} stops · ${fmt(poolVolume)}`}</Text>
179
- </CardFooter>
180
- </Card>
181
-
182
- {/* the capacity lanes */}
183
- <View style={{ flexGrow: 2, flexBasis: 560, gap: 16 }}>
184
- {TRUCKS.map((t) => {
185
- const load = loadOf(t.id);
186
- const truckStops = stopsOf(t.id);
187
- return (
188
- <Card key={t.id} style={{ padding: 0 }}>
189
- <CardHeader>
190
- <CardHeaderTitle info="The load meter is the gate: assignment options that would push it past capacity are disabled at the source.">
191
- {t.id}
192
- </CardHeaderTitle>
193
- <Peek
194
- accessibilityLabel={`Driver ${t.driver}`}
195
- side="bottom"
196
- align="end"
197
- content={
198
- <View style={{ gap: 12 }}>
199
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
200
- <Avatar name={t.driver} size={36} />
201
- <View style={{ gap: 1 }}>
202
- <Text size="sm" weight="semibold">{t.driver}</Text>
203
- <Text size="xs" color="muted">{`Driver · ${t.id}`}</Text>
204
- </View>
205
- </View>
206
- <Divider />
207
- <View style={{ gap: 8 }}>
208
- <DetailRow label="Vehicle" labelWidth={64} labelSize="xs"><Text size="sm" style={{ flex: 1 }} userSelect="auto">{`${t.id} · ${fmt(t.capacity)} capacity`}</Text></DetailRow>
209
- <DetailRow label="Today" labelWidth={64} labelSize="xs"><Text size="sm" style={{ flex: 1 }} userSelect="auto">{`${truckStops.length} stops · ${fmt(load)} loaded`}</Text></DetailRow>
210
- <DetailRow label="Phone" labelWidth={64} labelSize="xs"><Text size="sm" style={{ flex: 1 }} userSelect="auto">{t.phone}</Text></DetailRow>
211
- <DetailRow label="Licence" labelWidth={64} labelSize="xs"><Text size="sm" style={{ flex: 1 }} userSelect="auto">{t.licence}</Text></DetailRow>
212
- </View>
213
- <Button title="Open driver record" color="secondary" onPress={() => {}} />
214
- </View>
215
- }
216
- >
217
- <Text size="xs" color="muted">{t.driver}</Text>
218
- </Peek>
219
- </CardHeader>
220
- <View style={{ paddingHorizontal: 20, paddingVertical: 14 }}>
221
- <ProgressBar title="Load" value={load} max={t.capacity} format="fraction" color={solid("blue")} completeColor={solid("amber")} />
222
- </View>
223
- {truckStops.map((s, i) => (
224
- <View key={s.id}>
225
- {i > 0 ? <Divider /> : null}
226
- <StopRow
227
- s={s}
228
- openId={openId}
229
- onOpen={() => setOpenId(s.id)}
230
- right={
231
- <ActionMenu
232
- accessibilityLabel={`Actions for ${s.id}`}
233
- items={[{ key: "unassign", label: "Return to pool", icon: "arrow-left", onPress: () => assign(s.id, null) }]}
234
- />
235
- }
236
- />
237
- </View>
238
- ))}
239
- <CardFooter>
240
- <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
241
- {truckStops.length === 0 ? "Empty — assign from the pool" : `${truckStops.length} stops · ${fmt(load)} of ${fmt(t.capacity)}`}
242
- </Text>
243
- <Button title="Print run sheet" color="muted" onPress={() => {}} />
244
- </CardFooter>
245
- </Card>
246
- );
247
- })}
248
- </View>
249
- </View>
250
- </View>
251
-
252
- {open !== null ? (
253
- <Drawer
254
- open
255
- onOpenChange={(o) => !o && setOpenId(null)}
256
- title={open.id}
257
- width={440}
258
- onPrev={seqIndex > 0 ? () => setOpenId(walk[seqIndex - 1].id) : undefined}
259
- onNext={seqIndex >= 0 && seqIndex < walk.length - 1 ? () => setOpenId(walk[seqIndex + 1].id) : undefined}
260
- position={seqIndex >= 0 ? `${seqIndex + 1}/${walk.length}` : undefined}
261
- >
262
- <View key={open.id} style={{ flex: 1, padding: 24, gap: 14 }}>
263
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
264
- {open.truck ? <Badge label={`On ${open.truck}`} color="blue" /> : <Badge label="Unassigned" color="amber" />}
265
- <Badge label={open.due === "am" ? "Morning window" : "Afternoon window"} />
266
- </View>
267
- <View style={{ gap: 6 }}>
268
- <DetailRow label="Customer">
269
- <Text size="sm">{open.customer}</Text>
270
- </DetailRow>
271
- <Divider />
272
- <DetailRow label="Destination">
273
- <Text size="sm">{open.destination}</Text>
274
- </DetailRow>
275
- <Divider />
276
- <DetailRow label="Volume">
277
- <Text size="sm" tabular>{fmt(open.volume)}</Text>
278
- </DetailRow>
279
- </View>
280
- </View>
281
- <DrawerFooter>
282
- {open.truck !== null ? <Button title="Return to pool" color="secondary" onPress={() => assign(open.id, null)} /> : null}
283
- <Button title="Open order record" color="primary" onPress={() => {}} />
284
- </DrawerFooter>
285
- </Drawer>
286
- ) : null}
287
- </ScrollView>
288
- );
289
- }
@@ -1,299 +0,0 @@
1
- import { useState } from "react";
2
- import { ScrollView, View } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { colors, solid } from "@lotics/ui/colors";
5
- import { Badge } from "@lotics/ui/badge";
6
- import { Button } from "@lotics/ui/button";
7
- import { ChipGroup } from "@lotics/ui/chip_group";
8
- import { FilterChip } from "@lotics/ui/filter_chip";
9
- import { RangeSlider, rangeSummary } from "@lotics/ui/range_slider";
10
- import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
11
- import { Card } from "@lotics/ui/card";
12
- import { DetailRow } from "@lotics/ui/detail_row";
13
- import { Divider } from "@lotics/ui/divider";
14
- import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
15
- import { EmptyState } from "@lotics/ui/empty_state";
16
- import { formatMoney } from "@lotics/ui/format_money";
17
- import { KPIStrip } from "@lotics/ui/kpi_strip";
18
- import { ActionMenu } from "@lotics/ui/action_menu";
19
- import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
20
- import { ProgressBar } from "@lotics/ui/progress_bar";
21
- import { SearchInput } from "@lotics/ui/search_input";
22
-
23
- // ─────────────────────────────────────────────────────────────────────────────
24
- // Template · Warehouse — inventory. The stock register: each SKU is a row with
25
- // a stock-level bar (on hand vs minimum) that reads danger when under min, and
26
- // "Reorder" appears ONLY on under-min rows — the one action of the screen.
27
- //
28
- // Grammar: zinc-50 canvas · header + Stock count/Receive stock · ONE KPI Card ·
29
- // search + ChipGroup lens filtering live · register Card = eyebrow band → Divider
30
- // rows. Bar color: red under min, emerald at/over (ProgressBar completeColor).
31
- // Door: each SKU row press-opens the workspace Drawer, sequenced over the
32
- // filtered ordering (◀ ▶ / ←→) — open once, work the whole register.
33
- // ─────────────────────────────────────────────────────────────────────────────
34
-
35
- interface SkuRow {
36
- sku: string;
37
- ten: string;
38
- viTri: string;
39
- ton: number;
40
- dinhMuc: number;
41
- giaTri: number;
42
- /** dd/mm nearest expiry lot — feeds the "Expiring soon" KPI. */
43
- hetHan?: string;
44
- }
45
-
46
- const TON_KHO: SkuRow[] = [
47
- { sku: "RM-0012", ten: "Kraft paper 175gsm, 1.6m width", viTri: "A1-03", ton: 42, dinhMuc: 100, giaTri: 18_900_000 },
48
- { sku: "RM-0018", ten: "Medium paper 115gsm, 1.4m width", viTri: "A1-05", ton: 260, dinhMuc: 150, giaTri: 96_200_000 },
49
- { sku: "RM-0024", ten: "Flexo printing ink, blue", viTri: "B2-01", ton: 18, dinhMuc: 40, giaTri: 7_560_000, hetHan: "28/06" },
50
- { sku: "RM-0031", ten: "Starch adhesive, 25kg bag", viTri: "B2-04", ton: 65, dinhMuc: 50, giaTri: 13_650_000, hetHan: "15/07" },
51
- { sku: "SUP-0007", ten: "Clear packing tape 48mm", viTri: "C1-02", ton: 480, dinhMuc: 200, giaTri: 4_320_000 },
52
- { sku: "SUP-0011", ten: "PP strapping 12mm, 10kg roll", viTri: "C1-06", ton: 36, dinhMuc: 120, giaTri: 9_180_000 },
53
- { sku: "FG-0203", ten: "Carton box 600×400×400, 5-ply", viTri: "D3-01", ton: 1_250, dinhMuc: 800, giaTri: 23_750_000 },
54
- { sku: "FG-0218", ten: "Carton box 350×250×200, 3-ply", viTri: "D3-04", ton: 2_040, dinhMuc: 1_000, giaTri: 16_320_000 },
55
- { sku: "FG-0226", ten: "Offset box 250×180×90, 4-color print", viTri: "D4-02", ton: 640, dinhMuc: 500, giaTri: 28_800_000 },
56
- { sku: "SUP-0019", ten: "Staples 35mm, box of 5,000", viTri: "C2-03", ton: 22, dinhMuc: 60, giaTri: 1_980_000, hetHan: "30/06" },
57
- ];
58
-
59
- const LOC = [
60
- { id: "all", label: "All" },
61
- { id: "under_min", label: "Below minimum" },
62
- { id: "stocked", label: "Stocked" },
63
- ] as const;
64
-
65
- // Columns defined ONCE — Table renders the header + each cell's width; the
66
- // Reorder button + ⋯ live in the trailing gutter.
67
- const COLUMNS: TableColumn[] = [
68
- { key: "ten", label: "Item", flex: 1, sortable: true },
69
- { key: "viTri", label: "Location", width: 76, sortable: true },
70
- { key: "ton", label: "Stock level", width: 168, sortable: true },
71
- { key: "giaTri", label: "Stock value", width: 116, align: "right", sortable: true },
72
- ];
73
-
74
- const AMT_MAX = 100_000_000;
75
- const fmtAmt = (n: number) => formatMoney(n, { compact: true });
76
-
77
- function SkuLine({ r, selected, onPress }: { r: SkuRow; selected: boolean; onPress: () => void }) {
78
- const duoiMuc = r.ton < r.dinhMuc;
79
- return (
80
- <TableRow
81
- onPress={onPress}
82
- selected={selected}
83
- minHeight={56}
84
- accessibilityLabel={`Open ${r.sku} — ${r.ten}`}
85
- trailing={
86
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 6 }}>
87
- {duoiMuc ? <Button title="Reorder" color="muted" onPress={() => {}} /> : null}
88
- <ActionMenu
89
- accessibilityLabel={`Actions for ${r.sku}`}
90
- items={[
91
- { key: "adjust", label: "Adjust stock", icon: "pencil", onPress: () => {} },
92
- { key: "count", label: "Request stock count", icon: "calculator", onPress: () => {} },
93
- ]}
94
- />
95
- </View>
96
- }
97
- >
98
- <TableCell>
99
- <View style={{ gap: 2 }}>
100
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
101
- <Text size="sm" weight="medium" tabular>{r.sku}</Text>
102
- {r.hetHan ? <Badge label={`EXP ${r.hetHan}`} color="amber" /> : null}
103
- </View>
104
- <Text size="xs" color="muted" numberOfLines={1}>{r.ten}</Text>
105
- </View>
106
- </TableCell>
107
- <TableCell>
108
- <Badge label={r.viTri} color="zinc" />
109
- </TableCell>
110
- <TableCell>
111
- <View style={{ gap: 4, alignSelf: "stretch" }}>
112
- <ProgressBar value={r.ton} max={r.dinhMuc} format="none" color={solid("red")} completeColor={solid("emerald")} />
113
- <Text size="xs" color={duoiMuc ? "danger" : "muted"} tabular>
114
- {`On hand ${r.ton.toLocaleString("en-US")} / Min ${r.dinhMuc.toLocaleString("en-US")}`}
115
- </Text>
116
- </View>
117
- </TableCell>
118
- <TableCell>
119
- <Text size="sm" tabular>{formatMoney(r.giaTri)}</Text>
120
- </TableCell>
121
- </TableRow>
122
- );
123
- }
124
-
125
- // The SKU workspace behind a register row — compact: identity + key facts +
126
- // the reorder action. Body is keyed by sku from the caller so it resets as
127
- // ◀ ▶ steps the sequence.
128
- function SkuWorkspace({ r }: { r: SkuRow }) {
129
- const duoiMuc = r.ton < r.dinhMuc;
130
- return (
131
- <>
132
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
133
- <View style={{ gap: 8 }}>
134
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
135
- <Text size="lg" weight="semibold" tabular>{r.sku}</Text>
136
- <Badge label={duoiMuc ? "Below minimum" : "Stocked"} color={duoiMuc ? "red" : "emerald"} />
137
- {r.hetHan ? <Badge label={`EXP ${r.hetHan}`} color="amber" /> : null}
138
- </View>
139
- <Text size="sm" color="muted">{r.ten}</Text>
140
- </View>
141
- <Divider />
142
- <View style={{ gap: 8 }}>
143
- <DetailRow label="Bin location" labelWidth={120} minHeight={24}><Badge label={r.viTri} color="zinc" /></DetailRow>
144
- <DetailRow label="Stock value" labelWidth={120} minHeight={24}><Text size="sm" tabular>{formatMoney(r.giaTri)}</Text></DetailRow>
145
- {r.hetHan ? <DetailRow label="Nearest expiry" labelWidth={120} minHeight={24}><Text size="sm" tabular>{r.hetHan}</Text></DetailRow> : null}
146
- </View>
147
- <ProgressBar
148
- title="Stock level"
149
- value={r.ton}
150
- max={r.dinhMuc}
151
- format="fraction"
152
- color={solid("red")}
153
- completeColor={solid("emerald")}
154
- />
155
- </ScrollView>
156
- {/* pinned footer — the row's actions; Reorder only when below minimum */}
157
- <DrawerFooter>
158
- <Text size="xs" color="muted" style={{ flex: 1 }}>
159
- {duoiMuc ? "Stock is below the minimum — reorder to restore the safety level." : "Stock is at or above the minimum."}
160
- </Text>
161
- <Button title="Adjust stock" color="secondary" onPress={() => {}} />
162
- {duoiMuc ? <Button title="Reorder" color="primary" onPress={() => {}} /> : null}
163
- </DrawerFooter>
164
- </>
165
- );
166
- }
167
-
168
- export function TplInventory() {
169
- const [loc, setLoc] = useState<string>("all");
170
- const [tim, setTim] = useState("");
171
- const [openSku, setOpenSku] = useState<string | null>(null);
172
- const [sort, setSort] = useState<SortState | null>(null);
173
- const onSort = (key: string) => setSort(cycleSort(sort, key));
174
- const [amt, setAmt] = useState<[number, number]>([0, AMT_MAX]);
175
-
176
- const duoiDinhMuc = TON_KHO.filter((r) => r.ton < r.dinhMuc);
177
- const tongGiaTri = TON_KHO.reduce((s, r) => s + r.giaTri, 0);
178
- const sapHetHan = TON_KHO.filter((r) => r.hetHan !== undefined);
179
-
180
- const counts: Record<string, number> = {
181
- all: TON_KHO.length,
182
- under_min: duoiDinhMuc.length,
183
- stocked: TON_KHO.length - duoiDinhMuc.length,
184
- };
185
-
186
- const q = tim.trim().toLowerCase();
187
- const rows = TON_KHO.filter((r) => {
188
- if (loc === "under_min" && r.ton >= r.dinhMuc) return false;
189
- if (loc === "stocked" && r.ton < r.dinhMuc) return false;
190
- if (r.giaTri < amt[0] || r.giaTri > amt[1]) return false;
191
- if (q && !r.sku.toLowerCase().includes(q) && !r.ten.toLowerCase().includes(q)) return false;
192
- return true;
193
- });
194
- const amtSummary = rangeSummary(amt, fmtAmt, [0, AMT_MAX]);
195
-
196
- // Sort the filtered set — one column at a time (Stock level sorts by how far
197
- // below minimum: ton / dinhMuc, so the threshold breaches surface together).
198
- const sorted = sortBy(rows, sort, (r, key) =>
199
- key === "viTri" ? r.viTri
200
- : key === "ton" ? r.ton / r.dinhMuc
201
- : key === "giaTri" ? r.giaTri
202
- : r.ten.toLowerCase(),
203
- );
204
-
205
- // The drawer sequences over the CURRENTLY-VISIBLE ordering. If the open SKU
206
- // falls out of the filter, the chevrons disappear but the drawer stays on it.
207
- const openRow = openSku === null ? null : TON_KHO.find((r) => r.sku === openSku) ?? null;
208
- const seqIndex = openRow ? sorted.findIndex((r) => r.sku === openRow.sku) : -1;
209
-
210
- return (
211
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
212
- <View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
213
- {/* header band */}
214
- <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
215
- <View style={{ gap: 2, flex: 1 }}>
216
- <Text size="xl" weight="semibold">Warehouse — inventory</Text>
217
- <Text size="sm" color="muted">Track stock against minimums — reorder the moment a SKU dips below</Text>
218
- </View>
219
- <Button title="Stock count" color="secondary" onPress={() => {}} />
220
- <Button title="Receive stock" color="primary" onPress={() => {}} />
221
- </View>
222
-
223
- {/* KPI strip: ONE card, 4 hairline-divided columns. Counts that the
224
- tabs below already filter drill into the matching tab. */}
225
- <KPIStrip
226
- items={[
227
- {
228
- label: "Total SKUs",
229
- value: TON_KHO.length,
230
- format: "number",
231
- },
232
- { label: "Stock value", value: tongGiaTri, format: "currency", compact: true },
233
- {
234
- label: "Below minimum",
235
- value: duoiDinhMuc.length,
236
- format: "number",
237
- tone: "danger",
238
- info: "SKUs whose stock is under their reorder minimum — the Below minimum tab filters the register to them.",
239
-
240
- },
241
- { label: "Expiring soon", value: sapHetHan.length, format: "number" },
242
- ]}
243
- />
244
-
245
- {/* toolbar: live search + a stock-level lens. Wraps when narrow; every
246
- control is 40px so a wrapped band reads as clean rows. */}
247
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
248
- <View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
249
- <SearchInput
250
- placeholder="Search SKU, item name…"
251
- value={tim}
252
- onChangeText={setTim}
253
- accessibilityLabel="Search stock"
254
- />
255
- </View>
256
- <ChipGroup
257
- accessibilityLabel="Filter by stock level"
258
- options={LOC.map((l) => ({ label: `${l.label} · ${counts[l.id]}`, value: l.id }))}
259
- value={loc}
260
- onValueChange={setLoc}
261
- />
262
- <FilterChip label="Stock value" summary={amtSummary} onClear={() => setAmt([0, AMT_MAX])} clearLabel="Clear stock value filter">
263
- <RangeSlider min={0} max={AMT_MAX} step={5_000_000} value={amt} onValueChange={setAmt} format={fmtAmt} accessibilityLabel="Stock value" />
264
- </FilterChip>
265
- </View>
266
-
267
- {/* the stock register: eyebrow band → Divider-separated SKU rows */}
268
- <Card style={{ padding: 0 }}>
269
- <Table columns={COLUMNS} trailing={150} sort={sort} onSort={onSort}>
270
- {sorted.map((r) => (
271
- <SkuLine key={r.sku} r={r} selected={r.sku === openSku} onPress={() => setOpenSku(r.sku)} />
272
- ))}
273
- </Table>
274
- {rows.length === 0 ? (
275
- <>
276
- <Divider />
277
- <EmptyState message="No items match the current filters" hint="Try a different keyword or switch the filter tab" />
278
- </>
279
- ) : null}
280
- </Card>
281
- </View>
282
-
283
- {/* the SKU workspace — sequenced over the visible register ordering */}
284
- {openRow ? (
285
- <Drawer
286
- open
287
- onOpenChange={(o) => !o && setOpenSku(null)}
288
- title={openRow.ten}
289
- width={480}
290
- onPrev={seqIndex > 0 ? () => setOpenSku(sorted[seqIndex - 1].sku) : undefined}
291
- onNext={seqIndex >= 0 && seqIndex < sorted.length - 1 ? () => setOpenSku(sorted[seqIndex + 1].sku) : undefined}
292
- position={seqIndex >= 0 ? `${seqIndex + 1}/${sorted.length}` : undefined}
293
- >
294
- <SkuWorkspace key={openRow.sku} r={openRow} />
295
- </Drawer>
296
- ) : null}
297
- </ScrollView>
298
- );
299
- }