@lotics/ui 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +323 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,405 @@
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 { Alert } from "@lotics/ui/alert";
7
+ import { Badge } from "@lotics/ui/badge";
8
+ import { Button } from "@lotics/ui/button";
9
+ import { Card, CardBody, CardFooter, CardHeader, CardHeaderMeta } from "@lotics/ui/card";
10
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
11
+ import { Drawer } from "@lotics/ui/drawer";
12
+ import { EmptyState } from "@lotics/ui/empty_state";
13
+ import { Divider } from "@lotics/ui/divider";
14
+ import { FormField } from "@lotics/ui/form_field";
15
+ import { Icon } from "@lotics/ui/icon";
16
+ import { KPICard } from "@lotics/ui/kpi_card";
17
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
18
+ import { NumberInput } from "@lotics/ui/number_input";
19
+ import { Peek } from "@lotics/ui/peek";
20
+ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
21
+ import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
22
+ import { Screen } from "@lotics/ui/screen_router";
23
+ import { StepProgress } from "@lotics/ui/step_progress";
24
+ import { ChipGroup } from "@lotics/ui/chip_group";
25
+ import { TextInputField } from "@lotics/ui/text_input_field";
26
+ import { formatMoney } from "@lotics/ui/format_money";
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // App template · Order management — the KD03 pipeline desk, recreated in the
30
+ // harness with mock data so the UI can be polished in isolation and the
31
+ // result copied back into the app. This page IS the design spec.
32
+ //
33
+ // Grammar: full-bleed zinc-50 canvas · one header band · KPI strip · stage pills ·
34
+ // queue of work cards (CardHeader: identity · CardBody: product + stage
35
+ // StepProgress · CardFooter: KPICards → ONE action). The queue pill IS the
36
+ // macro pipeline position — cards carry no second pipeline indicator.
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ const CONG_DOAN = ["Blanking", "Cutting", "Die-cut", "Printing", "Gluing", "Inbound", "Outbound"];
40
+
41
+ interface MockOrder {
42
+ id: string;
43
+ khach: string;
44
+ ten: string;
45
+ queue: "lenh" | "sx" | "giao" | "doichieu";
46
+ cd?: number; // stage index
47
+ cdDays?: number;
48
+ can: string;
49
+ sl: string;
50
+ giaTri: number;
51
+ overdue?: boolean;
52
+ daGiao?: string;
53
+ khop?: boolean;
54
+ }
55
+
56
+ const ORDERS: MockOrder[] = [
57
+ { id: "SR-2026-0081", khach: "ATLAS COMPONENTS", ten: "Carton blank sheet 675×325 (HPKC00000005)", queue: "lenh", can: "10/04", sl: "2,747", giaTri: 8_982_690, overdue: true },
58
+ { id: "SR-2026-0082", khach: "ATLAS COMPONENTS", ten: "Full Cover Box 2.4MPa 1470×290×168", queue: "lenh", can: "10/04", sl: "1,162", giaTri: 62_829_340, overdue: true },
59
+ { id: "SR-2026-0022", khach: "CRESTLINE FURNITURE", ten: "TOP COVER · Long brace (11 pcs/set)", queue: "sx", cd: 5, cdDays: 3, can: "10/06", sl: "5,000", giaTri: 4_000_000, overdue: true },
60
+ { id: "SR-2026-0026", khach: "VITTORIA ACCESSORIES", ten: "Accessory carton box 400×300×250", queue: "sx", cd: 3, cdDays: 1, can: "13/06", sl: "2,500", giaTri: 22_500_000 },
61
+ { id: "SR-2026-0019", khach: "KING LUN PLASTICS", ten: "Inner box VTD063518 475×165×108", queue: "sx", cd: 1, cdDays: 0, can: "12/06", sl: "3,000", giaTri: 23_400_000 },
62
+ { id: "SR-2026-0027", khach: "BRIGHTCELL BATTERIES", ten: "TS1250 Phoenix box 371×191×124", queue: "giao", can: "16/06", sl: "500", giaTri: 4_250_000, daGiao: "0" },
63
+ { id: "SR-2026-0042", khach: "MERIDIAN CONSTRUCTION", ten: "Carton box NEW 2668 500×300×200", queue: "doichieu", can: "28/05", sl: "1,800", giaTri: 25_200_000, daGiao: "1,800", khop: true },
64
+ ];
65
+
66
+ const QUEUES = [
67
+ { id: "lenh", label: "Issue order", hint: "New orders — generate the production order for the contract shop" },
68
+ { id: "sx", label: "Production", hint: "Shop-floor stages — nudge anything stuck over 2 days" },
69
+ { id: "giao", label: "Delivery", hint: "Dispatch plans the trucks — track delivered quantity" },
70
+ { id: "doichieu", label: "Reconciliation", hint: "Match delivered vs the delivery note — when it matches, hand to Accounting" },
71
+ ] as const;
72
+
73
+ // Peek dossiers — the detail BEHIND each inline reference. A name on a card
74
+ // answers "who"; the peek answers "who, how to reach them, what they owe"
75
+ // without leaving the queue.
76
+ const KHACH_HO_SO: Record<string, { nganh: string; lienHe: string; dienThoai: string; doanhThu6T: number; congNo: number }> = {
77
+ "ATLAS COMPONENTS": { nganh: "Export machining", lienHe: "Sarah Chen — Procurement", dienThoai: "(206) 555-0147", doanhThu6T: 312_400_000, congNo: 86_200_000 },
78
+ "CRESTLINE FURNITURE": { nganh: "Export furniture", lienHe: "David Park — Logistics", dienThoai: "(415) 555-0228", doanhThu6T: 121_500_000, congNo: 64_200_000 },
79
+ "VITTORIA ACCESSORIES": { nganh: "Bicycle accessories", lienHe: "Maria Lopez — Purchasing", dienThoai: "(312) 555-0193", doanhThu6T: 154_700_000, congNo: 0 },
80
+ "KING LUN PLASTICS": { nganh: "Household plastics", lienHe: "James Wu — Sourcing", dienThoai: "(646) 555-0162", doanhThu6T: 248_900_000, congNo: 52_800_000 },
81
+ "BRIGHTCELL BATTERIES": { nganh: "Electrical — batteries", lienHe: "Emma Davis — Materials", dienThoai: "(503) 555-0184", doanhThu6T: 186_200_000, congNo: 86_400_000 },
82
+ "MERIDIAN CONSTRUCTION": { nganh: "Construction", lienHe: "Daniel Reed — Projects", dienThoai: "(212) 555-0119", doanhThu6T: 98_300_000, congNo: 0 },
83
+ };
84
+
85
+ function PeekRow({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
86
+ return (
87
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
88
+ <Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
89
+ <Text size="sm" color={danger ? "danger" : "default"} tabular>{value}</Text>
90
+ </View>
91
+ );
92
+ }
93
+
94
+ function KhachPeek({ ten }: { ten: string }) {
95
+ const hs = KHACH_HO_SO[ten];
96
+ return (
97
+ <View style={{ gap: 10 }}>
98
+ <View style={{ gap: 1 }}>
99
+ <Text size="sm" weight="semibold">{ten}</Text>
100
+ <Text size="xs" color="muted">{hs.nganh}</Text>
101
+ </View>
102
+ <Divider />
103
+ <PeekRow label="Contact" value={hs.lienHe} />
104
+ <PeekRow label="Phone" value={hs.dienThoai} />
105
+ <PeekRow label="Revenue (6 months)" value={formatMoney(hs.doanhThu6T)} />
106
+ <PeekRow label="Outstanding balance" value={hs.congNo > 0 ? formatMoney(hs.congNo) : "None"} danger={hs.congNo > 0} />
107
+ <Divider />
108
+ <Button title="Open customer record" color="secondary" onPress={() => {}} />
109
+ </View>
110
+ );
111
+ }
112
+
113
+ // The order's workspace — every detail, editable where it should be. Opened
114
+ // from the order id on any card; sequenced over the visible queue.
115
+ function OrderWorkspace({ o }: { o: MockOrder }) {
116
+ const [note, setNote] = useState("");
117
+ // The card's footer facts are read-only TRIAGE — editing lives HERE, in the
118
+ // workspace. Keyed by order id at the call site, so these reset per order.
119
+ const [can, setCan] = useState(o.can);
120
+ const [sl, setSl] = useState(o.sl);
121
+ const stuck = o.cdDays !== undefined && o.cdDays > 2;
122
+ return (
123
+ <>
124
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 14 }}>
125
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
126
+ <Badge label={QUEUES.find((q) => q.id === o.queue)?.label ?? ""} color="blue" />
127
+ {o.overdue ? <Badge label="Overdue" color="red" /> : null}
128
+ </View>
129
+ <Text size="sm">{o.ten}</Text>
130
+ {/* the SAME stage band as the card — full-width bar + caption below,
131
+ danger when stuck; card and drawer read identically */}
132
+ {o.queue === "sx" && o.cd !== undefined ? (
133
+ <StepProgress
134
+ steps={CONG_DOAN}
135
+ current={o.cd}
136
+ color={solid("emerald")}
137
+ captionBelow
138
+ label={stuck ? `Stuck ${o.cdDays}d · ${CONG_DOAN[o.cd]}` : undefined}
139
+ captionTone={stuck ? "danger" : "default"}
140
+ />
141
+ ) : null}
142
+ <Divider />
143
+ {/* customer = a reference (Peek); the two editable facts are real
144
+ inputs here; value/delivered stay read-only — value is contractual */}
145
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
146
+ <Text size="sm" color="muted" style={{ flex: 1 }}>Customer</Text>
147
+ <Peek accessibilityLabel={`Customer record ${o.khach}`} content={<KhachPeek ten={o.khach} />} align="end">
148
+ <Text size="sm" weight="medium">{o.khach}</Text>
149
+ </Peek>
150
+ </View>
151
+ <View style={{ flexDirection: "row", gap: 12 }}>
152
+ <FormField label="Needed by" style={{ flex: 1 }}>
153
+ <TextInputField value={can} onChangeText={setCan} inputMode="numeric" placeholder="dd/mm" />
154
+ </FormField>
155
+ <FormField label="Quantity" style={{ flex: 1 }}>
156
+ <TextInputField value={sl} onChangeText={setSl} inputMode="numeric" />
157
+ </FormField>
158
+ </View>
159
+ <View style={{ gap: 10 }}>
160
+ <PeekRow label="Value" value={formatMoney(o.giaTri)} />
161
+ {o.daGiao !== undefined ? <PeekRow label="Delivered" value={o.daGiao} /> : null}
162
+ </View>
163
+ <Divider />
164
+ <FormField label="Internal note" optional optionalLabel="Optional">
165
+ <TextInputField value={note} onChangeText={setNote} placeholder="Visible to the whole desk…" />
166
+ </FormField>
167
+ </ScrollView>
168
+ <View
169
+ style={{
170
+ borderTopWidth: 1,
171
+ borderTopColor: colors.border,
172
+ paddingHorizontal: 20,
173
+ paddingVertical: 14,
174
+ flexDirection: "row",
175
+ alignItems: "center",
176
+ gap: 12,
177
+ }}
178
+ >
179
+ <Text size="xs" color="muted" style={{ flex: 1 }}>The queue card stays read-only — edit here</Text>
180
+ <Button title="Open full record" color="secondary" onPress={() => {}} />
181
+ <Button title="Save changes" color="primary" onPress={() => {}} />
182
+ </View>
183
+ </>
184
+ );
185
+ }
186
+
187
+ // Stage gates, tiered by weight. Most transitions are one click (never add
188
+ // friction without need); a transition that NEEDS input gets a popover form
189
+ // anchored to its button (context stays visible); the exception/destructive
190
+ // path gets a Dialog (full attention, explicit framing).
191
+
192
+ function XuatKhoGuard({ o }: { o: MockOrder }) {
193
+ const [sl, setSl] = useState<number | null>(() => Number(o.sl.replace(/,/g, "")));
194
+ return (
195
+ <Popover side="top" align="end">
196
+ <PopoverTrigger>
197
+ <Button title="Confirm outbound" color="primary" />
198
+ </PopoverTrigger>
199
+ <PopoverContent style={{ width: 300 }} disableBodyScroll>
200
+ <View style={{ gap: 12 }}>
201
+ <View style={{ gap: 2 }}>
202
+ <Text size="sm" weight="semibold">Confirm outbound</Text>
203
+ <Text size="xs" color="muted">Record the warehouse's actual count — it may differ from the ordered quantity.</Text>
204
+ </View>
205
+ <FormField label="Outbound quantity">
206
+ <NumberInput value={sl} onValueChange={setSl} min={0} accessibilityLabel="Outbound quantity" />
207
+ </FormField>
208
+ </View>
209
+ {/* commit action right — Action Layout */}
210
+ <PopoverFooter>
211
+ <Button title="Confirm" color="primary" onPress={() => {}} />
212
+ </PopoverFooter>
213
+ </PopoverContent>
214
+ </Popover>
215
+ );
216
+ }
217
+
218
+ function LechGuard({ o }: { o: MockOrder }) {
219
+ // Dialog is CONTROLLED — the trigger button lives outside, owning open state.
220
+ const [open, setOpen] = useState(false);
221
+ const [lyDo, setLyDo] = useState("");
222
+ return (
223
+ <>
224
+ <Button title="Mismatch" color="muted" onPress={() => setOpen(true)} />
225
+ <Dialog width={460} open={open} onOpenChange={setOpen}>
226
+ <Screen route="">
227
+ <DialogHeader>
228
+ <DialogHeaderTitle>Report delivery mismatch — {o.id}</DialogHeaderTitle>
229
+ </DialogHeader>
230
+ <View style={{ padding: 24, gap: 14 }}>
231
+ <Text size="sm" color="muted">
232
+ Delivered quantity does not match the delivery note. The order moves to mismatch handling — Accounting
233
+ and Dispatch are both notified, and the balance stays unchanged until it is settled.
234
+ </Text>
235
+ <FormField label="Reason for mismatch">
236
+ <TextInputField value={lyDo} onChangeText={setLyDo} placeholder="e.g. 50 boxes short — dented during loading…" />
237
+ </FormField>
238
+ </View>
239
+ <DialogFooter>
240
+ <Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
241
+ <Button title="Confirm mismatch" color="danger" onPress={() => setOpen(false)} />
242
+ </DialogFooter>
243
+ </Screen>
244
+ </Dialog>
245
+ </>
246
+ );
247
+ }
248
+
249
+ function OrderCard({ o, onOpen }: { o: MockOrder; onOpen: () => void }) {
250
+ return (
251
+ <Card style={{ padding: 0 }}>
252
+ {/* the WHOLE card is the door to the order workspace (the header
253
+ chevron marks it). Nested pressables — the CTA, stage gates, the
254
+ customer Peek — claim their own presses via the responder system,
255
+ so they never open the drawer. The surface itself carries no
256
+ button role and no tab stop: a button must not contain interactive
257
+ descendants, so the ACCESSIBLE door is the order id inside — the
258
+ card around it is a mouse-only click extension. A pressable card
259
+ is not a text-selection surface → userSelect none. */}
260
+ <PressableHighlight onPress={onOpen} focusable={false} userSelect="none" style={{ borderRadius: 16 }}>
261
+ <CardHeader>
262
+ <Pressable accessibilityRole="button" accessibilityLabel={`Open order ${o.id}`} onPress={onOpen}>
263
+ <Text size="sm" weight="semibold" tabular>{o.id}</Text>
264
+ </Pressable>
265
+ {o.overdue ? <Badge label="Overdue" color="red" /> : null}
266
+ <View style={{ flex: 1 }} />
267
+ <Peek accessibilityLabel={`Customer record ${o.khach}`} content={<KhachPeek ten={o.khach} />} align="end">
268
+ <CardHeaderMeta>{o.khach}</CardHeaderMeta>
269
+ </Peek>
270
+ <ActionMenu
271
+ accessibilityLabel={`Actions for ${o.id}`}
272
+ items={[
273
+ { key: "sua", label: "Edit order", icon: "pencil", onPress: () => {} },
274
+ { key: "huy", label: "Cancel order", icon: "trash", danger: true, onPress: () => Alert.alert("Cancel order?", "This cancels the order and notifies the customer — it can't be undone.", [{ text: "Keep order", style: "cancel" }, { text: "Cancel order", style: "destructive", onPress: () => {} }]) },
275
+ ]}
276
+ />
277
+ <Icon name="chevron-right" size={16} color={colors.zinc[400]} />
278
+ </CardHeader>
279
+ <CardBody style={{ gap: 10 }}>
280
+ <Text size="sm" numberOfLines={1}>{o.ten}</Text>
281
+ {/* the queue pill already says the macro KD03 stage — the only
282
+ progress inside a card is the shop sub-stage. Full-width bar with
283
+ the stage in a caption BELOW it (matching the drawer); stuck turns
284
+ the caption danger — no floating badge that would shift the bar
285
+ and break card-to-card consistency. */}
286
+ {o.queue === "sx" && o.cd !== undefined ? (
287
+ <StepProgress
288
+ steps={CONG_DOAN}
289
+ current={o.cd}
290
+ color={solid("emerald")}
291
+ captionBelow
292
+ label={o.cdDays !== undefined && o.cdDays > 2 ? `Stuck ${o.cdDays}d · ${CONG_DOAN[o.cd]}` : undefined}
293
+ captionTone={o.cdDays !== undefined && o.cdDays > 2 ? "danger" : "default"}
294
+ />
295
+ ) : null}
296
+ </CardBody>
297
+ <CardFooter style={{ gap: 28 }}>
298
+ <KPICard label="Needed by" value={o.can} size="sm" tone={o.overdue ? "danger" : "default"} />
299
+ <KPICard label="Quantity" value={o.sl} size="sm" />
300
+ {o.daGiao !== undefined ? (
301
+ <KPICard label="Delivered" value={o.daGiao} size="sm" />
302
+ ) : (
303
+ <KPICard label="Value" value={o.giaTri} format="currency" size="sm" />
304
+ )}
305
+ <View style={{ flex: 1 }} />
306
+ {o.queue === "lenh" ? <Button title="Generate order" color="primary" onPress={() => {}} /> : null}
307
+ {o.queue === "sx" && o.cd !== undefined ? (
308
+ o.cd === 5 ? (
309
+ // gated: leaving the shop floor records the real counted quantity
310
+ <XuatKhoGuard o={o} />
311
+ ) : (
312
+ // ungated: advancing a stage needs nothing — one click
313
+ <Button title={`→ ${CONG_DOAN[o.cd + 1]}`} color="secondary" onPress={() => {}} />
314
+ )
315
+ ) : null}
316
+ {o.queue === "giao" ? <Badge label="Awaiting delivery" color="blue" /> : null}
317
+ {o.queue === "doichieu" ? (
318
+ <View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
319
+ {o.khop ? <Badge label="Quantities match" color="emerald" /> : null}
320
+ <Button title="Mark matched" color="primary" onPress={() => {}} />
321
+ {/* exception path: a dialog — full attention, reason required */}
322
+ <LechGuard o={o} />
323
+ </View>
324
+ ) : null}
325
+ </CardFooter>
326
+ </PressableHighlight>
327
+ </Card>
328
+ );
329
+ }
330
+
331
+ export function AppOrders() {
332
+ const [tab, setTab] = useState<(typeof QUEUES)[number]["id"]>("sx");
333
+ const [openId, setOpenId] = useState<string | null>(null);
334
+ const counts = Object.fromEntries(QUEUES.map((q) => [q.id, ORDERS.filter((o) => o.queue === q.id).length]));
335
+ const queue = ORDERS.filter((o) => o.queue === tab);
336
+ const def = QUEUES.find((q) => q.id === tab);
337
+ const quaHan = ORDERS.filter((o) => o.overdue).length;
338
+ // dd/mm → comparable day index (mock data lives in one year)
339
+ const dayIdx = (ddmm: string) => Number(ddmm.slice(3, 5)) * 31 + Number(ddmm.slice(0, 2));
340
+ const today = dayIdx("12/06");
341
+ const dueThisWeek = ORDERS.filter((o) => !o.overdue && dayIdx(o.can) >= today && dayIdx(o.can) <= today + 7).length;
342
+ const stuck = ORDERS.filter((o) => o.queue === "sx" && (o.cdDays ?? 0) > 2).length;
343
+ const openValue = ORDERS.reduce((sum, o) => sum + o.giaTri, 0);
344
+
345
+ // The workspace drawer sequences over the active queue's visible ordering.
346
+ const openOrder = ORDERS.find((o) => o.id === openId) ?? null;
347
+ const seqIndex = openOrder ? queue.findIndex((o) => o.id === openOrder.id) : -1;
348
+
349
+ return (
350
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
351
+ <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
352
+ {/* header band */}
353
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
354
+ <View style={{ gap: 2, flex: 1 }}>
355
+ <Text size="xl" weight="semibold">Order management</Text>
356
+ <Text size="sm" color="muted">KD03 process — issue order → production → delivery → reconciliation</Text>
357
+ </View>
358
+ </View>
359
+
360
+ {/* the strip carries cross-queue HEALTH — never the tab counts
361
+ (the pills already show those) */}
362
+ <KPIStrip
363
+ items={[
364
+ { label: "Overdue", value: quaHan, format: "number", tone: quaHan > 0 ? "danger" : "default", info: "Orders past their needed-by date, across every queue. The red badge on each card marks them." },
365
+ { label: "Due this week", value: dueThisWeek, format: "number", caption: "needed by within 7 days" },
366
+ { label: "Stuck in production", value: stuck, format: "number", tone: stuck > 0 ? "warning" : "default", info: "Production orders sitting in the same stage for more than 2 days — nudge the shop." },
367
+ { label: "Open value", value: openValue, format: "currency", compact: true, caption: "all unsettled orders" },
368
+ ]}
369
+ />
370
+
371
+ <ChipGroup
372
+ accessibilityLabel="Process step"
373
+ options={QUEUES.map((q) => ({ label: `${q.label} · ${counts[q.id]}`, value: q.id }))}
374
+ value={tab}
375
+ onValueChange={setTab}
376
+ />
377
+ {def ? <Text size="xs" color="muted">{def.hint}</Text> : null}
378
+
379
+ <View style={{ gap: 10 }}>
380
+ {queue.length === 0 ? (
381
+ <Card style={{ padding: 0 }}>
382
+ <EmptyState message="This queue is clear" hint="Orders land here as they reach this step" />
383
+ </Card>
384
+ ) : (
385
+ queue.map((o) => <OrderCard key={o.id} o={o} onOpen={() => setOpenId(o.id)} />)
386
+ )}
387
+ </View>
388
+ </View>
389
+
390
+ {openOrder ? (
391
+ <Drawer
392
+ open
393
+ onOpenChange={(open) => !open && setOpenId(null)}
394
+ title={openOrder.id}
395
+ width={520}
396
+ onPrev={seqIndex > 0 ? () => setOpenId(queue[seqIndex - 1].id) : undefined}
397
+ onNext={seqIndex >= 0 && seqIndex < queue.length - 1 ? () => setOpenId(queue[seqIndex + 1].id) : undefined}
398
+ position={seqIndex >= 0 ? `${seqIndex + 1}/${queue.length}` : undefined}
399
+ >
400
+ <OrderWorkspace key={openOrder.id} o={openOrder} />
401
+ </Drawer>
402
+ ) : null}
403
+ </ScrollView>
404
+ );
405
+ }
@@ -0,0 +1,120 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { AllocationRow } from "@lotics/ui/allocation_row";
6
+ import { Badge } from "@lotics/ui/badge";
7
+ import { Button } from "@lotics/ui/button";
8
+ import { Card, CardFooter } from "@lotics/ui/card";
9
+ import { Divider } from "@lotics/ui/divider";
10
+ import { RemainderMeter } from "@lotics/ui/remainder_meter";
11
+ import { formatMoney } from "@lotics/ui/format_money";
12
+
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Template · Allocation / split — apply ONE source across MANY targets until the
15
+ // remainder is zero. Here: cash application — a received payment spread across a
16
+ // customer's open invoices (the same shape serves stock-to-orders, landed-cost,
17
+ // budget distribution). The inverse of the batch builder: batch SUMS parts up to
18
+ // a total; allocation SPLITS a fixed total DOWN, with a remainder that must hit
19
+ // zero. The RemainderMeter is the spine — under (left on account) · exact (apply)
20
+ // · over (blocked). Oldest-first auto-allocates; each row can be filled or typed.
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ interface Invoice {
24
+ id: string;
25
+ issued: string;
26
+ ageDays: number;
27
+ /** Outstanding amount — the cap this invoice can absorb. */
28
+ due: number;
29
+ }
30
+
31
+ const PAYMENT = { amount: 24_500_000, from: "ATLAS COMPONENTS", ref: "TT-88412", date: "11/06" };
32
+
33
+ const INVOICES: Invoice[] = [
34
+ { id: "INV-2026-0301", issued: "24/05", ageDays: 18, due: 8_200_000 },
35
+ { id: "INV-2026-0305", issued: "31/05", ageDays: 11, due: 6_400_000 },
36
+ { id: "INV-2026-0312", issued: "07/06", ageDays: 4, due: 11_250_000 },
37
+ { id: "INV-2026-0318", issued: "09/06", ageDays: 2, due: 3_150_000 },
38
+ ];
39
+
40
+ export function TplAllocate() {
41
+ const [alloc, setAlloc] = useState<Record<string, number>>({});
42
+
43
+ const allocated = INVOICES.reduce((s, inv) => s + (alloc[inv.id] ?? 0), 0);
44
+ const remainder = PAYMENT.amount - allocated;
45
+ const over = remainder < 0;
46
+
47
+ // Oldest-first — fill the oldest invoices in full until the payment runs out.
48
+ const autoOldest = () => {
49
+ let left = PAYMENT.amount;
50
+ const next: Record<string, number> = {};
51
+ [...INVOICES].sort((a, b) => b.ageDays - a.ageDays).forEach((inv) => {
52
+ const take = Math.max(0, Math.min(inv.due, left));
53
+ next[inv.id] = take;
54
+ left -= take;
55
+ });
56
+ setAlloc(next);
57
+ };
58
+
59
+ const status = over
60
+ ? `Reduce by ${formatMoney(-remainder)} — you can't apply more than was received`
61
+ : remainder > 0
62
+ ? `${formatMoney(remainder)} will be left on the customer's account`
63
+ : "Payment fully applied across the selected invoices";
64
+
65
+ return (
66
+ <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
67
+ <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
68
+ {/* header */}
69
+ <View style={{ gap: 2 }}>
70
+ <Text size="xl" weight="semibold">Apply payment</Text>
71
+ <Text size="sm" color="muted">Split one received payment across the customer's open invoices — oldest first, or line by line</Text>
72
+ </View>
73
+
74
+ {/* the source — the payment, and the remainder as you place it */}
75
+ <Card style={{ padding: 0 }}>
76
+ <View style={{ padding: 20, flexDirection: "row", alignItems: "flex-start", gap: 24, flexWrap: "wrap" }}>
77
+ <View style={{ gap: 2, flexGrow: 1, flexBasis: 220 }}>
78
+ <Text size="xs" color="muted" transform="uppercase">Payment received</Text>
79
+ <Text size="xxl" weight="semibold" tabular>{formatMoney(PAYMENT.amount)}</Text>
80
+ <Text size="sm" color="muted">{`${PAYMENT.from} · ref ${PAYMENT.ref} · ${PAYMENT.date}`}</Text>
81
+ </View>
82
+ <View style={{ gap: 10, flexGrow: 1, flexBasis: 300 }}>
83
+ <RemainderMeter total={PAYMENT.amount} allocated={allocated} format={formatMoney} />
84
+ <View style={{ flexDirection: "row", gap: 8 }}>
85
+ <Button title="Oldest first" color="secondary" onPress={autoOldest} />
86
+ <Button title="Clear" color="muted" onPress={() => setAlloc({})} />
87
+ </View>
88
+ </View>
89
+ </View>
90
+ </Card>
91
+
92
+ {/* the targets — the open invoices, each absorbing part of the payment */}
93
+ <Card style={{ padding: 0 }}>
94
+ <View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
95
+ <Text size="xs" color="muted" transform="uppercase">{`Open invoices · ${INVOICES.length}`}</Text>
96
+ </View>
97
+ <Divider />
98
+ {INVOICES.map((inv, i) => (
99
+ <View key={inv.id}>
100
+ {i > 0 ? <Divider /> : null}
101
+ <AllocationRow
102
+ label={inv.id}
103
+ sublabel={`Issued ${inv.issued}`}
104
+ cap={inv.due}
105
+ value={alloc[inv.id] ?? 0}
106
+ onValueChange={(n) => setAlloc((prev) => ({ ...prev, [inv.id]: n }))}
107
+ format={formatMoney}
108
+ trailing={<Badge variant="dot" label={`${inv.ageDays}d`} color={inv.ageDays > 14 ? "red" : "zinc"} />}
109
+ />
110
+ </View>
111
+ ))}
112
+ <CardFooter>
113
+ <Text size="xs" color={over ? "danger" : "muted"} style={{ flex: 1 }}>{status}</Text>
114
+ <Button title="Apply payment" color="primary" disabled={over || allocated === 0} onPress={() => {}} />
115
+ </CardFooter>
116
+ </Card>
117
+ </View>
118
+ </ScrollView>
119
+ );
120
+ }