@lotics/ui 7.8.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.
- package/AGENTS.md +71 -32
- package/examples/tpl_allocate.tsx +6 -6
- package/examples/tpl_assistant.tsx +1 -1
- package/examples/tpl_attendance.tsx +1 -1
- package/examples/tpl_billing.tsx +2 -2
- package/examples/tpl_briefing.tsx +1 -1
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_compare.tsx +1 -1
- package/examples/tpl_crosscheck.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_detail.tsx +1 -1
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_directory.tsx +5 -8
- package/examples/tpl_draft.tsx +2 -2
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_intake.tsx +1 -1
- package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
- package/examples/tpl_lookup.tsx +2 -2
- package/examples/tpl_match.tsx +2 -2
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pick.tsx +64 -68
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_quick.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +1 -1
- package/examples/tpl_record_plain.tsx +1 -1
- package/examples/tpl_report.tsx +1 -1
- package/examples/tpl_rollup.tsx +1 -1
- package/examples/tpl_settings.tsx +1 -1
- package/examples/tpl_shifts.tsx +1 -1
- package/examples/tpl_stock.tsx +1 -1
- package/examples/tpl_task_board.tsx +2 -2
- package/examples/tpl_tasks.tsx +1 -1
- package/examples/tpl_timeline.tsx +1 -1
- package/examples/tpl_tower.tsx +1 -1
- package/examples/tpl_triage.tsx +1 -1
- package/examples/tpl_wizard.tsx +1 -1
- package/package.json +3 -1
- package/src/card.tsx +6 -13
- package/src/data_grid.tsx +1 -1
- package/src/kpi_strip.tsx +5 -5
- package/src/pressable_row.tsx +22 -10
- package/src/sort_header.tsx +9 -4
- package/src/summary_line.tsx +76 -0
- package/src/table.tsx +27 -11
- package/src/use_selection.test.ts +66 -0
- package/src/use_selection.ts +75 -0
- package/examples/app_orders.tsx +0 -405
- package/examples/tpl_approvals.tsx +0 -375
- package/examples/tpl_batch.tsx +0 -234
- package/examples/tpl_callsheet.tsx +0 -481
- package/examples/tpl_convert.tsx +0 -478
- package/examples/tpl_crm_desk.tsx +0 -541
- package/examples/tpl_dispatch.tsx +0 -289
- package/examples/tpl_inventory.tsx +0 -299
- package/examples/tpl_pipeline.tsx +0 -400
- package/examples/tpl_reconcile.tsx +0 -275
- 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
|
-
}
|