@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.
- package/AGENTS.md +352 -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_billing.tsx +344 -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 +12 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +33 -8
- package/src/control_surface.ts +8 -0
- 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/index.css +6 -3
- package/src/inline_date_picker.tsx +111 -0
- package/src/inline_edit.tsx +238 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +92 -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/link.tsx +32 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/picker.tsx +4 -1
- package/src/popover.tsx +10 -1
- package/src/pressable_row.tsx +4 -1
- package/src/radio_picker.tsx +3 -1
- package/src/section_heading.tsx +43 -29
- package/src/segmented_control.tsx +3 -2
- package/src/tabs.tsx +4 -2
- package/src/tag_input.tsx +202 -0
- package/src/text.tsx +1 -1
- package/src/time_picker.tsx +15 -3
- 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
|
+
}
|