@lotics/ui 3.6.0 → 4.1.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 (65) hide show
  1. package/AGENTS.md +352 -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_billing.tsx +344 -0
  8. package/examples/tpl_calendar.tsx +288 -0
  9. package/examples/tpl_callsheet.tsx +481 -0
  10. package/examples/tpl_convert.tsx +490 -0
  11. package/examples/tpl_crm_desk.tsx +541 -0
  12. package/examples/tpl_dashboard.tsx +554 -0
  13. package/examples/tpl_detail.tsx +232 -0
  14. package/examples/tpl_directory.tsx +263 -0
  15. package/examples/tpl_dispatch.tsx +289 -0
  16. package/examples/tpl_dossier.tsx +431 -0
  17. package/examples/tpl_intake.tsx +206 -0
  18. package/examples/tpl_inventory.tsx +299 -0
  19. package/examples/tpl_order.tsx +483 -0
  20. package/examples/tpl_pick.tsx +240 -0
  21. package/examples/tpl_quick.tsx +210 -0
  22. package/examples/tpl_reconcile.tsx +275 -0
  23. package/examples/tpl_record.tsx +301 -0
  24. package/examples/tpl_record_plain.tsx +154 -0
  25. package/examples/tpl_rollup.tsx +300 -0
  26. package/examples/tpl_run.tsx +235 -0
  27. package/examples/tpl_settings.tsx +178 -0
  28. package/examples/tpl_shifts.tsx +421 -0
  29. package/examples/tpl_stock.tsx +387 -0
  30. package/examples/tpl_timeline.tsx +244 -0
  31. package/examples/tpl_tower.tsx +356 -0
  32. package/examples/tpl_wizard.tsx +223 -0
  33. package/package.json +12 -2
  34. package/src/bar_chart.tsx +5 -0
  35. package/src/combobox.tsx +33 -8
  36. package/src/control_surface.ts +8 -0
  37. package/src/form_date_picker.tsx +2 -0
  38. package/src/form_picker.tsx +1 -0
  39. package/src/form_switch.tsx +1 -0
  40. package/src/form_text_input.tsx +2 -0
  41. package/src/icon.tsx +2 -0
  42. package/src/icon_button.tsx +5 -2
  43. package/src/index.css +6 -3
  44. package/src/inline_date_picker.tsx +111 -0
  45. package/src/inline_edit.tsx +238 -0
  46. package/src/inline_number_input.tsx +70 -0
  47. package/src/inline_select.tsx +92 -0
  48. package/src/inline_text_input.tsx +71 -0
  49. package/src/inline_time_picker.tsx +64 -0
  50. package/src/line_chart.tsx +4 -0
  51. package/src/link.tsx +32 -0
  52. package/src/list_item.tsx +5 -0
  53. package/src/number_input.tsx +12 -1
  54. package/src/page_content.tsx +5 -0
  55. package/src/picker.tsx +4 -1
  56. package/src/popover.tsx +10 -1
  57. package/src/pressable_row.tsx +4 -1
  58. package/src/radio_picker.tsx +3 -1
  59. package/src/section_heading.tsx +43 -29
  60. package/src/segmented_control.tsx +3 -2
  61. package/src/tabs.tsx +4 -2
  62. package/src/tag_input.tsx +202 -0
  63. package/src/text.tsx +1 -1
  64. package/src/time_picker.tsx +15 -3
  65. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,289 @@
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
+ }