@lotics/ui 3.6.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 (54) 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/combobox.tsx +22 -6
  35. package/src/form_date_picker.tsx +2 -0
  36. package/src/form_picker.tsx +1 -0
  37. package/src/form_switch.tsx +1 -0
  38. package/src/form_text_input.tsx +2 -0
  39. package/src/icon.tsx +2 -0
  40. package/src/icon_button.tsx +5 -2
  41. package/src/inline_date_picker.tsx +110 -0
  42. package/src/inline_edit.tsx +228 -0
  43. package/src/inline_number_input.tsx +70 -0
  44. package/src/inline_select.tsx +91 -0
  45. package/src/inline_text_input.tsx +71 -0
  46. package/src/inline_time_picker.tsx +64 -0
  47. package/src/line_chart.tsx +4 -0
  48. package/src/list_item.tsx +5 -0
  49. package/src/number_input.tsx +12 -1
  50. package/src/page_content.tsx +5 -0
  51. package/src/section_heading.tsx +43 -29
  52. package/src/tag_input.tsx +202 -0
  53. package/src/time_picker.tsx +15 -3
  54. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,387 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors, ramp, solid, type ColorName } from "@lotics/ui/colors";
5
+ import { ActionMenu } from "@lotics/ui/action_menu";
6
+ import { Badge } from "@lotics/ui/badge";
7
+ import { Breakdown } from "@lotics/ui/breakdown";
8
+ import { Button } from "@lotics/ui/button";
9
+ import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
10
+ import { Divider } from "@lotics/ui/divider";
11
+ import { Drawer } from "@lotics/ui/drawer";
12
+ import { EmptyState } from "@lotics/ui/empty_state";
13
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
14
+ import { Pagination } from "@lotics/ui/pagination";
15
+ import { PillButton } from "@lotics/ui/pill_button";
16
+ import { PressableRow } from "@lotics/ui/pressable_row";
17
+ import { SearchInput } from "@lotics/ui/search_input";
18
+ import { formatMoney } from "@lotics/ui/format_money";
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Template · Stock overview — monitoring a population too large to browse
22
+ // (10,000+ units). The funnel: KPI strip (population health) → Breakdown
23
+ // cards (composition by dimension; pressing a segment is the drill-down) →
24
+ // dismissible facet chips + search → PAGINATED register (the data is the
25
+ // reason Pagination exists) → row press opens the sequenced unit drawer,
26
+ // ⋯ carries quick operations. Facets combine across dimensions, and each
27
+ // Breakdown's counts respect the OTHER dimensions' selections — true
28
+ // faceted search.
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ // Type/depot are COHERENT dimensions with no inherent meaning per category —
32
+ // one hue family each, shaded by a `ramp` (the label carries identity, the
33
+ // shade only orders). Status colors carry MEANING, so each is its own
34
+ // `ColorName` (one reference; the Breakdown solid()s it, the row Badge takes
35
+ // it directly). No hand-picked hex anywhere.
36
+ const TYPES = [
37
+ { key: "dry20", label: "Dry 20ft" },
38
+ { key: "dry40", label: "Dry 40ft" },
39
+ { key: "hc40", label: "High-cube 40ft" },
40
+ { key: "reefer", label: "Reefer 40ft" },
41
+ { key: "opentop", label: "Open-top" },
42
+ { key: "tank", label: "Tank" },
43
+ ] as const;
44
+ const TYPE_RAMP = ramp("blue", TYPES.length);
45
+
46
+ const STATUSES = [
47
+ { key: "leased", label: "Leased out", color: "emerald" },
48
+ { key: "yard", label: "In yard", color: "zinc" },
49
+ { key: "transit", label: "In transit", color: "blue" },
50
+ { key: "repair", label: "Under repair", color: "amber" },
51
+ { key: "idle", label: "Idle >30 days", color: "red" },
52
+ ] as const satisfies readonly { key: string; label: string; color: ColorName }[];
53
+
54
+ const DEPOTS = [
55
+ { key: "eastport", label: "Eastport" },
56
+ { key: "northgate", label: "Northgate" },
57
+ { key: "riverside", label: "Riverside" },
58
+ { key: "southbay", label: "Southbay" },
59
+ ] as const;
60
+ const DEPOT_RAMP = ramp("violet", DEPOTS.length);
61
+
62
+ type TypeKey = (typeof TYPES)[number]["key"];
63
+ type StatusKey = (typeof STATUSES)[number]["key"];
64
+ type DepotKey = (typeof DEPOTS)[number]["key"];
65
+
66
+ interface Unit {
67
+ id: string;
68
+ type: TypeKey;
69
+ status: StatusKey;
70
+ depot: DepotKey;
71
+ ageYears: number;
72
+ lastMove: string;
73
+ value: number;
74
+ }
75
+
76
+ // 10,482 deterministic units — integer hashing instead of Math.random so the
77
+ // page is stable across reloads. Distributions are intentionally uneven
78
+ // (real fleets are): lots of dry 40ft, few tanks; most units leased.
79
+ function hash(i: number, salt: number): number {
80
+ let x = (i + 1) * 2654435761 + salt * 40503;
81
+ x = ((x >>> 16) ^ x) * 0x45d9f3b;
82
+ x = ((x >>> 16) ^ x) * 0x45d9f3b;
83
+ return (x >>> 16) % 1000;
84
+ }
85
+
86
+ function pick<T>(i: number, salt: number, weighted: [T, number][]): T {
87
+ const total = weighted.reduce((s, [, w]) => s + w, 0);
88
+ let roll = hash(i, salt) % total;
89
+ for (const [item, w] of weighted) {
90
+ if (roll < w) return item;
91
+ roll -= w;
92
+ }
93
+ return weighted[0][0];
94
+ }
95
+
96
+ const TYPE_BASE_VALUE: Record<TypeKey, number> = {
97
+ dry20: 38_000_000, dry40: 62_000_000, hc40: 71_000_000,
98
+ reefer: 240_000_000, opentop: 88_000_000, tank: 310_000_000,
99
+ };
100
+
101
+ const UNITS: Unit[] = Array.from({ length: 10_482 }, (_, i) => {
102
+ const type = pick<TypeKey>(i, 1, [["dry40", 34], ["dry20", 26], ["hc40", 18], ["reefer", 12], ["opentop", 7], ["tank", 3]]);
103
+ const status = pick<StatusKey>(i, 2, [["leased", 52], ["yard", 18], ["transit", 14], ["repair", 9], ["idle", 7]]);
104
+ const depot = pick<DepotKey>(i, 3, [["eastport", 38], ["northgate", 27], ["riverside", 21], ["southbay", 14]]);
105
+ const ageYears = 1 + (hash(i, 4) % 12);
106
+ const day = 1 + (hash(i, 5) % 28);
107
+ const month = status === "idle" ? 1 + (hash(i, 6) % 4) : 4 + (hash(i, 6) % 3);
108
+ const value = Math.round((TYPE_BASE_VALUE[type] * (100 - ageYears * 4 + (hash(i, 7) % 10))) / 100 / 100_000) * 100_000;
109
+ return {
110
+ id: `CSU-${String(i + 1).padStart(6, "0")}`,
111
+ type, status, depot, ageYears,
112
+ lastMove: `${String(day).padStart(2, "0")}/${String(month).padStart(2, "0")}`,
113
+ value,
114
+ };
115
+ });
116
+
117
+ const PAGE_SIZE = 12;
118
+
119
+ const typeOf = (k: TypeKey) => TYPES.find((t) => t.key === k)!;
120
+ const statusOf = (k: StatusKey) => STATUSES.find((s) => s.key === k)!;
121
+ const depotOf = (k: DepotKey) => DEPOTS.find((d) => d.key === k)!;
122
+
123
+ function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
124
+ return (
125
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
126
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
127
+ {children}
128
+ </View>
129
+ );
130
+ }
131
+
132
+ export function TplStock() {
133
+ const [type, setType] = useState<string | null>(null);
134
+ const [status, setStatus] = useState<string | null>(null);
135
+ const [depot, setDepot] = useState<string | null>(null);
136
+ const [search, setSearch] = useState("");
137
+ const [page, setPage] = useState(0);
138
+ const [openIdx, setOpenIdx] = useState<number | null>(null);
139
+
140
+ // Facets combine with AND; any change resets paging.
141
+ const setFacet = (set: (v: string | null) => void) => (v: string | null) => {
142
+ set(v);
143
+ setPage(0);
144
+ setOpenIdx(null);
145
+ };
146
+
147
+ const matches = (u: Unit, skip?: "type" | "status" | "depot") =>
148
+ (skip === "type" || !type || u.type === type) &&
149
+ (skip === "status" || !status || u.status === status) &&
150
+ (skip === "depot" || !depot || u.depot === depot) &&
151
+ (search.trim() === "" || u.id.toLowerCase().includes(search.trim().toLowerCase()));
152
+
153
+ // Each dimension's counts respect the OTHER dimensions — the segment you
154
+ // could still drill into, not the unfiltered universe.
155
+ const { byType, byStatus, byDepot, filtered } = useMemo(() => {
156
+ const byType = new Map<string, number>();
157
+ const byStatus = new Map<string, number>();
158
+ const byDepot = new Map<string, number>();
159
+ const filtered: Unit[] = [];
160
+ for (const u of UNITS) {
161
+ if (matches(u, "type")) byType.set(u.type, (byType.get(u.type) ?? 0) + 1);
162
+ if (matches(u, "status")) byStatus.set(u.status, (byStatus.get(u.status) ?? 0) + 1);
163
+ if (matches(u, "depot")) byDepot.set(u.depot, (byDepot.get(u.depot) ?? 0) + 1);
164
+ if (matches(u)) filtered.push(u);
165
+ }
166
+ return { byType, byStatus, byDepot, filtered };
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [type, status, depot, search]);
169
+
170
+ const pageRows = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
171
+ const open = openIdx !== null ? pageRows[openIdx] : null;
172
+
173
+ // Population health — cross-cutting numbers no facet card shows.
174
+ const idle = UNITS.filter((u) => u.status === "idle").length;
175
+ const repair = UNITS.filter((u) => u.status === "repair").length;
176
+ const working = UNITS.filter((u) => u.status === "leased" || u.status === "transit").length;
177
+ const fleetValue = useMemo(() => UNITS.reduce((s, u) => s + u.value, 0), []);
178
+
179
+ const chips: { key: string; label: string; clear: () => void }[] = [
180
+ ...(type ? [{ key: "type", label: typeOf(type as TypeKey).label, clear: () => setFacet(setType)(null) }] : []),
181
+ ...(status ? [{ key: "status", label: statusOf(status as StatusKey).label, clear: () => setFacet(setStatus)(null) }] : []),
182
+ ...(depot ? [{ key: "depot", label: depotOf(depot as DepotKey).label, clear: () => setFacet(setDepot)(null) }] : []),
183
+ ];
184
+
185
+ return (
186
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
187
+ <View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
188
+ {/* header band */}
189
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
190
+ <View style={{ gap: 2, flex: 1 }}>
191
+ <Text size="xl" weight="semibold">Stock overview</Text>
192
+ <Text size="sm" color="muted">The whole fleet at a glance — drill into any slice, then walk the units one by one</Text>
193
+ </View>
194
+ <Button title="Register units" color="primary" onPress={() => {}} />
195
+ </View>
196
+
197
+ {/* population health — numbers no single facet shows */}
198
+ <KPIStrip
199
+ items={[
200
+ { label: "Total units", value: UNITS.length, format: "number" },
201
+ { label: "Utilization", value: Math.round((working / UNITS.length) * 100), format: "percentage", info: "Share of the fleet leased out or in transit — units earning, not sitting." },
202
+ { label: "Idle >30 days", value: idle, format: "number", tone: "danger", info: "Units with no movement for over 30 days — candidates for repositioning or sale. Drill in via the status breakdown." },
203
+ { label: "Fleet value", value: fleetValue, format: "currency", compact: true, caption: `${repair.toLocaleString("en-US")} under repair` },
204
+ ]}
205
+ />
206
+
207
+ {/* composition — press a segment to drill; selections combine */}
208
+ <View style={{ flexDirection: "row", gap: 16, alignItems: "stretch", flexWrap: "wrap" }}>
209
+ <Card style={{ padding: 0, flexGrow: 1, flexBasis: 300 }}>
210
+ <CardHeader>
211
+ <CardHeaderTitle info="Fleet composition by container type. Press a type to filter the register below — combine with status and depot.">
212
+ By type
213
+ </CardHeaderTitle>
214
+ </CardHeader>
215
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
216
+ <Breakdown
217
+ items={TYPES.map((t, i) => ({ key: t.key, label: t.label, value: byType.get(t.key) ?? 0, color: TYPE_RAMP[i] }))}
218
+ selectedKey={type}
219
+ onSelect={setFacet(setType)}
220
+ />
221
+ </View>
222
+ </Card>
223
+ <Card style={{ padding: 0, flexGrow: 1, flexBasis: 300 }}>
224
+ <CardHeader>
225
+ <CardHeaderTitle info="Where every unit stands right now. Idle and repair are the slices to watch.">
226
+ By status
227
+ </CardHeaderTitle>
228
+ </CardHeader>
229
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
230
+ <Breakdown
231
+ items={STATUSES.map((s) => ({ key: s.key, label: s.label, value: byStatus.get(s.key) ?? 0, color: solid(s.color) }))}
232
+ selectedKey={status}
233
+ onSelect={setFacet(setStatus)}
234
+ />
235
+ </View>
236
+ </Card>
237
+ <Card style={{ padding: 0, flexGrow: 1, flexBasis: 300 }}>
238
+ <CardHeader>
239
+ <CardHeaderTitle info="Units by home depot — spot imbalances worth a repositioning run.">
240
+ By depot
241
+ </CardHeaderTitle>
242
+ </CardHeader>
243
+ <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
244
+ <Breakdown
245
+ items={DEPOTS.map((d, i) => ({ key: d.key, label: d.label, value: byDepot.get(d.key) ?? 0, color: DEPOT_RAMP[i] }))}
246
+ selectedKey={depot}
247
+ onSelect={setFacet(setDepot)}
248
+ />
249
+ </View>
250
+ </Card>
251
+ </View>
252
+
253
+ {/* the register — paginated; facets + search feed it */}
254
+ <Card style={{ padding: 0 }}>
255
+ <CardHeader>
256
+ <CardHeaderTitle info="Every unit matching the active slice. Press a row to open the unit; ⋯ for quick operations.">
257
+ Units
258
+ </CardHeaderTitle>
259
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
260
+ {chips.map((c) => (
261
+ <PillButton key={c.key} onDismiss={c.clear} dismissTooltip="Clear filter">
262
+ <Text size="xs" weight="medium" color="muted">{c.label}</Text>
263
+ </PillButton>
264
+ ))}
265
+ <View style={{ width: 240 }}>
266
+ <SearchInput
267
+ placeholder="Search by unit no…"
268
+ value={search}
269
+ onChangeText={(t) => { setSearch(t); setPage(0); }}
270
+ accessibilityLabel="Search units"
271
+ />
272
+ </View>
273
+ </View>
274
+ </CardHeader>
275
+
276
+ {/* eyebrow columns */}
277
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
278
+ <Text size="xs" color="muted" transform="uppercase" style={{ width: 110 }}>Unit no</Text>
279
+ <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>Type</Text>
280
+ <Text size="xs" color="muted" transform="uppercase" style={{ width: 96 }}>Depot</Text>
281
+ <Text size="xs" color="muted" transform="uppercase" style={{ width: 76 }}>Last move</Text>
282
+ <Text size="xs" color="muted" transform="uppercase" tabular align="right" style={{ width: 104 }}>Value</Text>
283
+ <Text size="xs" color="muted" transform="uppercase" style={{ width: 110 }}>Status</Text>
284
+ <View style={{ width: 28 }} />
285
+ </View>
286
+ <Divider />
287
+
288
+ {pageRows.length === 0 ? (
289
+ <EmptyState message="No units match this slice" hint="Clear a filter chip or adjust the search" />
290
+ ) : (
291
+ pageRows.map((u, i) => (
292
+ <View key={u.id}>
293
+ {i > 0 ? <Divider /> : null}
294
+ <PressableRow onPress={() => setOpenIdx(i)} selected={open?.id === u.id} style={{ gap: 8 }}>
295
+ <Pressable
296
+ accessibilityRole="button"
297
+ accessibilityLabel={`Open ${u.id}`}
298
+ onPress={() => setOpenIdx(i)}
299
+ style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 44 }}
300
+ >
301
+ <Text size="sm" weight="medium" tabular style={{ width: 110 }}>{u.id}</Text>
302
+ <Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{typeOf(u.type).label}</Text>
303
+ <Text size="sm" color="muted" style={{ width: 96 }}>{depotOf(u.depot).label}</Text>
304
+ <Text size="sm" tabular style={{ width: 76 }}>{u.lastMove}</Text>
305
+ <Text size="sm" tabular align="right" style={{ width: 104 }}>{formatMoney(u.value)}</Text>
306
+ {/* browse register: status is a secondary attribute (the
307
+ triage signal lives in the By-status breakdown + Idle
308
+ KPI) — the LIGHT dot reads cleaner across many rows than
309
+ a tonal pill. The drawer header keeps the tonal pill. */}
310
+ <View style={{ width: 116 }}>
311
+ <Badge variant="dot" label={statusOf(u.status).label} color={statusOf(u.status).color} />
312
+ </View>
313
+ </Pressable>
314
+ <ActionMenu
315
+ accessibilityLabel={`Actions for ${u.id}`}
316
+ items={[
317
+ { key: "move", label: "Plan repositioning", icon: "arrow-right", onPress: () => {} },
318
+ { key: "repair", label: "Send to repair", icon: "settings", disabled: u.status === "repair", onPress: () => {} },
319
+ { key: "retire", label: "Retire unit", icon: "ban", danger: true, onPress: () => {} },
320
+ ]}
321
+ />
322
+ </PressableRow>
323
+ </View>
324
+ ))
325
+ )}
326
+
327
+ <CardFooter>
328
+ <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
329
+ {`${filtered.length.toLocaleString("en-US")} units in this slice · ${formatMoney(filtered.reduce((s, u) => s + u.value, 0), { compact: true })}`}
330
+ </Text>
331
+ <Pagination
332
+ page={page}
333
+ pageSize={PAGE_SIZE}
334
+ rowCount={pageRows.length}
335
+ hasMore={(page + 1) * PAGE_SIZE < filtered.length}
336
+ total={filtered.length}
337
+ onPageChange={(p) => { setPage(p); setOpenIdx(null); }}
338
+ />
339
+ </CardFooter>
340
+ </Card>
341
+ </View>
342
+
343
+ {open !== null && openIdx !== null ? (
344
+ <Drawer
345
+ open
346
+ onOpenChange={(o) => !o && setOpenIdx(null)}
347
+ title={open.id}
348
+ width={440}
349
+ onPrev={openIdx > 0 ? () => setOpenIdx(openIdx - 1) : undefined}
350
+ onNext={openIdx < pageRows.length - 1 ? () => setOpenIdx(openIdx + 1) : undefined}
351
+ position={`${page * PAGE_SIZE + openIdx + 1}/${filtered.length.toLocaleString("en-US")}`}
352
+ >
353
+ <View key={open.id} style={{ flex: 1, padding: 24, gap: 12 }}>
354
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
355
+ <Badge label={statusOf(open.status).label} color={statusOf(open.status).color} />
356
+ <Badge label={typeOf(open.type).label} />
357
+ </View>
358
+ <View style={{ gap: 6, paddingTop: 4 }}>
359
+ <KVRow label="Depot"><Text size="sm">{depotOf(open.depot).label}</Text></KVRow>
360
+ <Divider />
361
+ <KVRow label="Last movement"><Text size="sm" tabular>{open.lastMove}</Text></KVRow>
362
+ <Divider />
363
+ <KVRow label="Age"><Text size="sm" tabular>{`${open.ageYears} years`}</Text></KVRow>
364
+ <Divider />
365
+ <KVRow label="Book value"><Text size="sm" tabular>{formatMoney(open.value)}</Text></KVRow>
366
+ </View>
367
+ </View>
368
+ <View
369
+ style={{
370
+ borderTopWidth: 1,
371
+ borderTopColor: colors.border,
372
+ paddingHorizontal: 20,
373
+ paddingVertical: 14,
374
+ flexDirection: "row",
375
+ alignItems: "center",
376
+ justifyContent: "flex-end",
377
+ gap: 12,
378
+ }}
379
+ >
380
+ <Button title="Plan repositioning" color="secondary" onPress={() => {}} />
381
+ <Button title="Open unit record" color="primary" onPress={() => {}} />
382
+ </View>
383
+ </Drawer>
384
+ ) : null}
385
+ </ScrollView>
386
+ );
387
+ }
@@ -0,0 +1,244 @@
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 { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
6
+ import { Badge } from "@lotics/ui/badge";
7
+ import { Card, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
8
+ import { Icon, type IconName } from "@lotics/ui/icon";
9
+ import { ChipGroup } from "@lotics/ui/chip_group";
10
+ import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
11
+ import { KPICard } from "@lotics/ui/kpi_card";
12
+ import { formatMoney } from "@lotics/ui/format_money";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Template · Activity log — the audit feed of ONE record (an order).
16
+ // Grammar: zinc-50 canvas · header band · filter pills (ChipGroup) · one Card
17
+ // per DAY, each card = date band → Divider → Timeline. Icon color = event
18
+ // category accent (blue = documents, emerald = payments, zinc = system,
19
+ // red = warnings). Times right-aligned tabular; expandable details on the
20
+ // events that carry numbers. No dead rows: events without inline details
21
+ // carry an ActionMenu (⋯) with the event's real operations — open/download
22
+ // the document, view the workflow run, re-send the reminder.
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ type Cat = "chungtu" | "thanhtoan" | "hethong";
26
+
27
+ interface MockEvent {
28
+ id: string;
29
+ cat: Cat;
30
+ icon: IconName;
31
+ color: string;
32
+ label: string;
33
+ time: string; // HH:mm
34
+ stats?: { label: string; value: string; tone?: "danger" }[];
35
+ file?: string;
36
+ note?: string;
37
+ actions?: ActionMenuItem[];
38
+ }
39
+
40
+ interface DayGroup {
41
+ id: string;
42
+ label: string; // "Today · 12/06"
43
+ events: MockEvent[];
44
+ }
45
+
46
+ const DAYS: DayGroup[] = [
47
+ {
48
+ id: "d0",
49
+ label: "Today · 12/06",
50
+ events: [
51
+ {
52
+ id: "e1", cat: "thanhtoan", icon: "circle-check", color: solid("emerald"),
53
+ label: "Accounting confirmed installment 2 — order settled", time: "14:32",
54
+ stats: [
55
+ { label: "Amount", value: formatMoney(12_600_000) },
56
+ { label: "Method", value: "Bank transfer" },
57
+ { label: "Outstanding", value: formatMoney(0) },
58
+ ],
59
+ note: "Reference TRX-0612-114 · First National Bank",
60
+ },
61
+ {
62
+ id: "e2", cat: "chungtu", icon: "file-up", color: solid("blue"),
63
+ label: "Nina Adams uploaded the signed delivery note", time: "11:05",
64
+ file: "DeliveryNote_DN-0042_signed.pdf",
65
+ actions: [
66
+ { key: "xem", label: "View document", icon: "eye", onPress: () => {} },
67
+ { key: "tai", label: "Download", icon: "download", onPress: () => {} },
68
+ ],
69
+ },
70
+ {
71
+ id: "e3", cat: "hethong", icon: "bolt", color: colors.zinc[500],
72
+ label: "Delivery-reconciliation workflow ran — quantities match the note", time: "11:06",
73
+ actions: [
74
+ { key: "chay", label: "View run", icon: "history", onPress: () => {} },
75
+ { key: "mo", label: "Open workflow", icon: "external-link", onPress: () => {} },
76
+ ],
77
+ },
78
+ {
79
+ id: "e4", cat: "hethong", icon: "bell", color: colors.zinc[500],
80
+ label: "Reminded Accounting: order awaiting invoice", time: "11:06",
81
+ actions: [
82
+ { key: "xem", label: "View notification", icon: "bell", onPress: () => {} },
83
+ { key: "nhac", label: "Remind again", icon: "repeat", onPress: () => {} },
84
+ ],
85
+ },
86
+ ],
87
+ },
88
+ {
89
+ id: "d1",
90
+ label: "Yesterday · 11/06",
91
+ events: [
92
+ {
93
+ id: "e5", cat: "chungtu", icon: "file-text", color: solid("blue"),
94
+ label: "VAT invoice issued from the Sales invoice template", time: "16:40",
95
+ file: "Invoice_INV-2026-0584.pdf",
96
+ actions: [
97
+ { key: "xem", label: "View invoice", icon: "eye", onPress: () => {} },
98
+ { key: "tai", label: "Download", icon: "download", onPress: () => {} },
99
+ { key: "gui", label: "Send to customer", icon: "send", onPress: () => {} },
100
+ ],
101
+ },
102
+ {
103
+ id: "e6", cat: "thanhtoan", icon: "credit-card", color: solid("emerald"),
104
+ label: "Installment 1 recorded (50% per contract)", time: "10:18",
105
+ stats: [
106
+ { label: "Amount", value: formatMoney(12_600_000) },
107
+ { label: "Outstanding", value: formatMoney(12_600_000), tone: "danger" },
108
+ ],
109
+ },
110
+ {
111
+ id: "e7", cat: "hethong", icon: "circle-alert", color: solid("red"),
112
+ label: "Warning: delivered 2 days past the needed-by date", time: "08:00",
113
+ note: "Needed by 09/06 — truck actually delivered 11/06. Logged against the delivery KPI.",
114
+ },
115
+ ],
116
+ },
117
+ {
118
+ id: "d2",
119
+ label: "Tuesday · 09/06",
120
+ events: [
121
+ {
122
+ id: "e8", cat: "hethong", icon: "pencil", color: colors.zinc[500],
123
+ label: "Mark Vu edited the Expected delivery date field", time: "15:22",
124
+ stats: [
125
+ { label: "Before", value: "09/06" },
126
+ { label: "After", value: "11/06" },
127
+ ],
128
+ note: "Reason: gluing line jammed at the plant — customer notified by chat.",
129
+ },
130
+ {
131
+ id: "e9", cat: "chungtu", icon: "send", color: solid("blue"),
132
+ label: "Approved quote emailed to the customer", time: "09:47",
133
+ file: "Quote_QT-2026-0042.pdf",
134
+ actions: [
135
+ { key: "xem", label: "View quote", icon: "eye", onPress: () => {} },
136
+ { key: "tai", label: "Download", icon: "download", onPress: () => {} },
137
+ { key: "gui", label: "Re-send email", icon: "send", onPress: () => {} },
138
+ ],
139
+ },
140
+ {
141
+ id: "e10", cat: "hethong", icon: "circle-check", color: solid("emerald"),
142
+ label: "Director approved the order — moved to Production order", time: "09:30",
143
+ actions: [
144
+ { key: "duyet", label: "View approval", icon: "eye", onPress: () => {} },
145
+ { key: "lien-ket", label: "Copy link", icon: "copy", onPress: () => {} },
146
+ ],
147
+ },
148
+ ],
149
+ },
150
+ ];
151
+
152
+ const FILTERS = [
153
+ { value: "all", label: "All" },
154
+ { value: "chungtu", label: "Documents" },
155
+ { value: "thanhtoan", label: "Payments" },
156
+ { value: "hethong", label: "System" },
157
+ ] as const;
158
+
159
+ function EventDetails({ ev }: { ev: MockEvent }) {
160
+ return (
161
+ <View style={{ gap: 8, paddingVertical: 4 }}>
162
+ {ev.stats ? (
163
+ <View style={{ flexDirection: "row", gap: 28 }}>
164
+ {ev.stats.map((s) => (
165
+ <KPICard key={s.label} label={s.label} value={s.value} format="none" size="sm" tone={s.tone} />
166
+ ))}
167
+ </View>
168
+ ) : null}
169
+ {ev.note ? <Text size="xs" color="muted">{ev.note}</Text> : null}
170
+ </View>
171
+ );
172
+ }
173
+
174
+ function toTimelineItem(ev: MockEvent): TimelineItem {
175
+ const hasDetails = !!ev.stats || !!ev.note;
176
+ return {
177
+ id: ev.id,
178
+ icon: ev.icon,
179
+ iconColor: ev.color,
180
+ label: ev.label,
181
+ right: (
182
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
183
+ {ev.file ? (
184
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
185
+ <Icon name="file" size={12} color={colors.zinc[400]} />
186
+ <Text size="xs" color="muted" numberOfLines={1}>{ev.file}</Text>
187
+ </View>
188
+ ) : null}
189
+ <Text size="xs" color="muted" tabular>{ev.time}</Text>
190
+ {ev.actions ? (
191
+ <ActionMenu items={ev.actions} accessibilityLabel={`Actions: ${ev.label}`} />
192
+ ) : null}
193
+ </View>
194
+ ),
195
+ details: hasDetails ? <EventDetails ev={ev} /> : undefined,
196
+ };
197
+ }
198
+
199
+ export function TplTimeline() {
200
+ const [filter, setFilter] = useState<string>("all");
201
+
202
+ const all = DAYS.flatMap((d) => d.events);
203
+ const countOf = (v: string) => (v === "all" ? all.length : all.filter((e) => e.cat === v).length);
204
+ const days = DAYS
205
+ .map((d) => ({ ...d, events: filter === "all" ? d.events : d.events.filter((e) => e.cat === filter) }))
206
+ .filter((d) => d.events.length > 0);
207
+
208
+ return (
209
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
210
+ <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
211
+ {/* header band */}
212
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
213
+ <View style={{ gap: 2, flex: 1 }}>
214
+ <Text size="xl" weight="semibold">Activity log</Text>
215
+ <Text size="sm" color="muted">Order SO-2026-0042 · NEWTECONS — who did what, when, with the paper trail</Text>
216
+ </View>
217
+ <Badge label="Settled" color="emerald" />
218
+ </View>
219
+
220
+ {/* filter band — pills narrow the feed by event type */}
221
+ <ChipGroup
222
+ accessibilityLabel="Filter activity"
223
+ options={FILTERS.map((f) => ({ label: `${f.label} · ${countOf(f.value)}`, value: f.value }))}
224
+ value={filter}
225
+ onValueChange={setFilter}
226
+ />
227
+
228
+ {/* one card per day: date band → hairline → timeline */}
229
+ {days.map((d) => (
230
+ <Card key={d.id} style={{ padding: 0 }}>
231
+ <CardHeader>
232
+ <CardHeaderTitle>{d.label}</CardHeaderTitle>
233
+ <CardHeaderMeta>{`${d.events.length} events`}</CardHeaderMeta>
234
+ </CardHeader>
235
+ {/* Last Timeline row carries its own 14px tail — trim the band's bottom to keep 16px optical. */}
236
+ <View style={{ paddingTop: 16, paddingHorizontal: 16, paddingBottom: 2 }}>
237
+ <Timeline items={d.events.map(toTimelineItem)} />
238
+ </View>
239
+ </Card>
240
+ ))}
241
+ </View>
242
+ </ScrollView>
243
+ );
244
+ }