@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.
- package/AGENTS.md +323 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/callout.tsx +50 -17
- package/src/combobox.tsx +22 -6
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- 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
|
+
}
|