@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,299 @@
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 { FilterPill } from "@lotics/ui/filter_pill";
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
+ <FilterPill 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
+ </FilterPill>
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
+ }