@lotics/ui 7.8.0 → 7.10.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 (63) hide show
  1. package/AGENTS.md +92 -43
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record_plain.tsx +121 -16
  26. package/examples/tpl_report.tsx +1 -1
  27. package/examples/tpl_rollup.tsx +1 -1
  28. package/examples/tpl_settings.tsx +1 -1
  29. package/examples/tpl_shifts.tsx +1 -1
  30. package/examples/tpl_stock.tsx +1 -1
  31. package/examples/tpl_task_board.tsx +2 -2
  32. package/examples/tpl_tasks.tsx +1 -1
  33. package/examples/tpl_timeline.tsx +1 -1
  34. package/examples/tpl_tower.tsx +1 -1
  35. package/examples/tpl_triage.tsx +1 -1
  36. package/examples/tpl_wizard.tsx +1 -1
  37. package/package.json +5 -1
  38. package/src/card.tsx +6 -13
  39. package/src/danger_zone.tsx +58 -0
  40. package/src/data_grid.tsx +1 -1
  41. package/src/detail_row.tsx +8 -1
  42. package/src/inline_edit.tsx +14 -4
  43. package/src/inline_select.tsx +9 -8
  44. package/src/inline_static.tsx +63 -0
  45. package/src/kpi_strip.tsx +5 -5
  46. package/src/pressable_row.tsx +22 -10
  47. package/src/sort_header.tsx +9 -4
  48. package/src/summary_line.tsx +76 -0
  49. package/src/table.tsx +27 -11
  50. package/src/use_selection.test.ts +66 -0
  51. package/src/use_selection.ts +75 -0
  52. package/examples/app_orders.tsx +0 -405
  53. package/examples/tpl_approvals.tsx +0 -375
  54. package/examples/tpl_batch.tsx +0 -234
  55. package/examples/tpl_callsheet.tsx +0 -481
  56. package/examples/tpl_convert.tsx +0 -478
  57. package/examples/tpl_crm_desk.tsx +0 -541
  58. package/examples/tpl_dispatch.tsx +0 -289
  59. package/examples/tpl_inventory.tsx +0 -299
  60. package/examples/tpl_pipeline.tsx +0 -400
  61. package/examples/tpl_reconcile.tsx +0 -275
  62. package/examples/tpl_record.tsx +0 -302
  63. package/examples/tpl_run.tsx +0 -235
@@ -1,375 +0,0 @@
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 { Avatar } from "@lotics/ui/avatar";
6
- import { Badge } from "@lotics/ui/badge";
7
- import { Button } from "@lotics/ui/button";
8
- import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
9
- import { CheckboxInput } from "@lotics/ui/checkbox_input";
10
- import { DetailRow } from "@lotics/ui/detail_row";
11
- import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
12
- import { Divider } from "@lotics/ui/divider";
13
- import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
14
- import { EmptyState } from "@lotics/ui/empty_state";
15
- import { Counter } from "@lotics/ui/counter";
16
- import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
17
- import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
18
- import { FormField } from "@lotics/ui/form_field";
19
- import { KPIStrip } from "@lotics/ui/kpi_strip";
20
- import { OptionList } from "@lotics/ui/option_list";
21
- import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
22
- import { RangeSlider, rangeSummary } from "@lotics/ui/range_slider";
23
- import { PressableRow } from "@lotics/ui/pressable_row";
24
- import { Screen } from "@lotics/ui/screen_router";
25
- import { ChipGroup } from "@lotics/ui/chip_group";
26
- import { TextInputField } from "@lotics/ui/text_input_field";
27
- import { Timeline } from "@lotics/ui/timeline";
28
- import { formatMoney } from "@lotics/ui/format_money";
29
-
30
- // ─────────────────────────────────────────────────────────────────────────────
31
- // Template · Approvals — the work is a VERDICT, not a process step. The
32
- // design splits routine from exception: in-policy requests approve in one
33
- // click (or in bulk via checkboxes + the floating bar); over-policy
34
- // requests lose the checkbox and force the drawer, where the policy band
35
- // shows exactly what's being overridden, and approval is a Dialog with
36
- // the consequence spelled out. Reject always asks one question — why —
37
- // in a popover. The row press opens the sequenced request workspace.
38
- // ─────────────────────────────────────────────────────────────────────────────
39
-
40
- interface Request {
41
- id: string;
42
- type: "purchase" | "discount" | "expense";
43
- requester: string;
44
- role: string;
45
- summary: string;
46
- amount: number;
47
- /** The policy ceiling for this request kind/requester. */
48
- limit: number;
49
- ageDays: number;
50
- note: string;
51
- }
52
-
53
- const REQUESTS: Request[] = [
54
- { id: "REQ-0231", type: "purchase", requester: "Maria Lopez", role: "Procurement", summary: "Corrugated sheets — restock for the June run", amount: 18_400_000, limit: 20_000_000, ageDays: 1, note: "Supplier quote attached; same unit price as May." },
55
- { id: "REQ-0232", type: "purchase", requester: "James Wu", role: "Production", summary: "Die-cut blade replacement set", amount: 6_750_000, limit: 20_000_000, ageDays: 2, note: "Current blades past rated cycles." },
56
- { id: "REQ-0234", type: "purchase", requester: "Maria Lopez", role: "Procurement", summary: "Ink — spot color for ATLAS order", amount: 23_400_000, limit: 20_000_000, ageDays: 6, note: "Rush order; only one supplier stocks this color." },
57
- { id: "REQ-0236", type: "purchase", requester: "David Park", role: "Warehouse", summary: "Pallet wrap, 40 rolls", amount: 3_120_000, limit: 20_000_000, ageDays: 1, note: "Routine restock." },
58
- { id: "REQ-0228", type: "discount", requester: "Sarah Chen", role: "Sales", summary: "CRESTLINE — 8% on repeat order SR-2026-0091", amount: 4_980_000, limit: 6_000_000, ageDays: 3, note: "Customer at 96% on-time payment; volume up 20% QoQ." },
59
- { id: "REQ-0237", type: "discount", requester: "Daniel Reed", role: "Sales", summary: "NEW client trial — 15% on first order", amount: 9_300_000, limit: 6_000_000, ageDays: 4, note: "Competitor quote attached; strategic account." },
60
- { id: "REQ-0229", type: "expense", requester: "Emma Davis", role: "Sales", summary: "Client visit — Haiphong, 2 days", amount: 2_840_000, limit: 5_000_000, ageDays: 2, note: "Itinerary attached." },
61
- { id: "REQ-0233", type: "expense", requester: "James Wu", role: "Production", summary: "Forklift repair — emergency callout", amount: 7_600_000, limit: 5_000_000, ageDays: 5, note: "Invoice from the service vendor attached." },
62
- { id: "REQ-0235", type: "expense", requester: "David Park", role: "Warehouse", summary: "Safety gloves and boots, Q3", amount: 1_950_000, limit: 5_000_000, ageDays: 1, note: "Annual PPE refresh." },
63
- ];
64
-
65
- const TYPE_LABEL: Record<Request["type"], string> = { purchase: "Purchase", discount: "Discount", expense: "Expense" };
66
-
67
- // Distinct facet values, derived from the data — the secondary filter dimensions.
68
- const REQUESTERS = [...new Set(REQUESTS.map((r) => r.requester))].map((n) => ({ label: n, value: n }));
69
- const ROLES = [...new Set(REQUESTS.map((r) => r.role))].map((n) => ({ label: n, value: n }));
70
- const AMT_MIN = 0;
71
- const AMT_MAX = 25_000_000;
72
-
73
- const overPolicy = (r: Request) => r.amount > r.limit;
74
- const overPct = (r: Request) => Math.round(((r.amount - r.limit) / r.limit) * 100);
75
-
76
- function RejectGate({ id, onReject }: { id: string; onReject: () => void }) {
77
- const [reason, setReason] = useState("");
78
- return (
79
- <Popover side="top" align="end">
80
- <PopoverTrigger>
81
- <Button title="Reject" color="muted" />
82
- </PopoverTrigger>
83
- <PopoverContent style={{ width: 300 }} disableBodyScroll>
84
- <View style={{ gap: 12 }}>
85
- <View style={{ gap: 2 }}>
86
- <Text size="sm" weight="semibold">{`Reject ${id}`}</Text>
87
- <Text size="xs" color="muted">The requester sees this reason — say what would change the decision.</Text>
88
- </View>
89
- <FormField label="Reason">
90
- <TextInputField value={reason} onChangeText={setReason} placeholder="e.g. defer to July budget…" />
91
- </FormField>
92
- </View>
93
- <PopoverFooter>
94
- <Button title="Reject request" color="danger" onPress={onReject} />
95
- </PopoverFooter>
96
- </PopoverContent>
97
- </Popover>
98
- );
99
- }
100
-
101
- function OverrideGate({ r, onApprove }: { r: Request; onApprove: () => void }) {
102
- const [open, setOpen] = useState(false);
103
- return (
104
- <>
105
- <Button title="Approve over policy" color="primary" onPress={() => setOpen(true)} />
106
- <Dialog width={460} open={open} onOpenChange={setOpen}>
107
- <Screen route="">
108
- <DialogHeader>
109
- <DialogHeaderTitle>{`Approve ${r.id} over policy`}</DialogHeaderTitle>
110
- </DialogHeader>
111
- <View style={{ padding: 24, gap: 10 }}>
112
- <Text size="sm" color="muted">
113
- {`This ${TYPE_LABEL[r.type].toLowerCase()} is ${formatMoney(r.amount - r.limit)} (+${overPct(r)}%) above the ${formatMoney(r.limit)} policy ceiling. Your approval is recorded as a policy override and appears in the monthly exceptions report.`}
114
- </Text>
115
- </View>
116
- <DialogFooter>
117
- <Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
118
- <Button title="Approve and record override" color="primary" onPress={() => { setOpen(false); onApprove(); }} />
119
- </DialogFooter>
120
- </Screen>
121
- </Dialog>
122
- </>
123
- );
124
- }
125
-
126
- function RequestWorkspace({ r, onDecide }: { r: Request; onDecide: () => void }) {
127
- return (
128
- <>
129
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 14 }}>
130
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
131
- <Badge label={TYPE_LABEL[r.type]} color="blue" />
132
- {overPolicy(r) ? <Badge label={`Over limit +${overPct(r)}%`} color="red" /> : <Badge label="In policy" color="emerald" />}
133
- </View>
134
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
135
- <Avatar name={r.requester} size={32} />
136
- <View style={{ gap: 0 }}>
137
- <Text size="sm" weight="medium">{r.requester}</Text>
138
- <Text size="xs" color="muted">{r.role}</Text>
139
- </View>
140
- </View>
141
- <Text size="sm">{r.summary}</Text>
142
- <Text size="sm" color="muted">{r.note}</Text>
143
- <Divider />
144
- {/* the policy band — the one comparison the verdict rests on */}
145
- <View style={{ gap: 6 }}>
146
- <DetailRow label="Requested">
147
- <Text size="sm" weight="semibold" tabular>{formatMoney(r.amount)}</Text>
148
- </DetailRow>
149
- <Divider />
150
- <DetailRow label="Policy ceiling">
151
- <Text size="sm" tabular>{formatMoney(r.limit)}</Text>
152
- </DetailRow>
153
- <Divider />
154
- <DetailRow label="Headroom">
155
- <Text size="sm" tabular color={overPolicy(r) ? "danger" : "default"}>
156
- {overPolicy(r) ? `−${formatMoney(r.amount - r.limit)} (+${overPct(r)}%)` : formatMoney(r.limit - r.amount)}
157
- </Text>
158
- </DetailRow>
159
- </View>
160
- <View style={{ gap: 8, paddingTop: 4 }}>
161
- <Text size="sm" weight="semibold">History</Text>
162
- <Timeline
163
- items={[
164
- { id: "submit", icon: "send", iconColor: solid("blue"), label: `Submitted by ${r.requester}`, right: <Text size="xs" color="muted" tabular>{`${r.ageDays}d ago`}</Text> },
165
- { id: "routed", icon: "check", iconColor: solid("emerald"), label: "Budget line verified — routed to you", right: <Text size="xs" color="muted" tabular>{`${Math.max(0, r.ageDays - 1)}d ago`}</Text> },
166
- ]}
167
- />
168
- </View>
169
- </ScrollView>
170
- <DrawerFooter>
171
- <RejectGate id={r.id} onReject={onDecide} />
172
- {overPolicy(r) ? <OverrideGate r={r} onApprove={onDecide} /> : <Button title="Approve" color="primary" onPress={onDecide} />}
173
- </DrawerFooter>
174
- </>
175
- );
176
- }
177
-
178
- export function TplApprovals() {
179
- const [pending, setPending] = useState<Request[]>(REQUESTS);
180
- const [tab, setTab] = useState("all");
181
- const [selected, setSelected] = useState<Set<string>>(new Set());
182
- const [openId, setOpenId] = useState<string | null>(null);
183
- const [reqSel, setReqSel] = useState<string[]>([]);
184
- const [roleSel, setRoleSel] = useState<string[]>([]);
185
- const [amt, setAmt] = useState<[number, number]>([AMT_MIN, AMT_MAX]);
186
- const [minDays, setMinDays] = useState(0);
187
-
188
- const visible = pending.filter(
189
- (r) =>
190
- (tab === "all" || r.type === tab) &&
191
- (reqSel.length === 0 || reqSel.includes(r.requester)) &&
192
- (roleSel.length === 0 || roleSel.includes(r.role)) &&
193
- r.amount >= amt[0] &&
194
- r.amount <= amt[1] &&
195
- r.ageDays >= minDays,
196
- );
197
- const open = pending.find((r) => r.id === openId) ?? null;
198
- const seqIndex = open ? visible.findIndex((r) => r.id === open.id) : -1;
199
-
200
- const over = pending.filter(overPolicy).length;
201
- const oldest = pending.reduce((m, r) => Math.max(m, r.ageDays), 0);
202
- const totalValue = pending.reduce((s, r) => s + r.amount, 0);
203
-
204
- // A decision removes the request from the inbox — approve and reject both
205
- // resolve it here; the record lives on in its own register.
206
- const decide = (ids: string[]) => {
207
- setPending((prev) => prev.filter((r) => !ids.includes(r.id)));
208
- setSelected((prev) => new Set([...prev].filter((id) => !ids.includes(id))));
209
- setOpenId(null);
210
- };
211
-
212
- const routineVisible = visible.filter((r) => !overPolicy(r));
213
- const allRoutineSelected = routineVisible.length > 0 && routineVisible.every((r) => selected.has(r.id));
214
-
215
- const counts = (t: string) => pending.filter((r) => t === "all" || r.type === t).length;
216
-
217
- const fmtAmt = (n: number) => formatMoney(n, { compact: true });
218
- const reqSummary = selectSummary(reqSel, REQUESTERS);
219
- const roleSummary = selectSummary(roleSel, ROLES, { max: 3 });
220
- const amtSummary = rangeSummary(amt, fmtAmt, [AMT_MIN, AMT_MAX]);
221
- const daysSummary = minDays > 0 ? `≥ ${minDays}d` : undefined;
222
-
223
- return (
224
- <View style={{ flex: 1 }}>
225
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
226
- <View style={{ width: "100%", maxWidth: 980, alignSelf: "center", gap: 16 }}>
227
- {/* header band */}
228
- <View style={{ gap: 2 }}>
229
- <Text size="xl" weight="semibold">Approvals</Text>
230
- <Text size="sm" color="muted">Decide, don't process — routine requests in bulk, exceptions with full attention</Text>
231
- </View>
232
-
233
- <KPIStrip
234
- items={[
235
- { label: "Awaiting decision", value: pending.length, format: "number" },
236
- { label: "Over policy", value: over, format: "number", tone: over > 0 ? "danger" : "default", info: "Requests above their policy ceiling — no checkbox, no one-click: each one opens its workspace and approval is recorded as an override." },
237
- { label: "Oldest waiting", value: oldest, format: "number", caption: "days — requesters are blocked" },
238
- { label: "Requested value", value: totalValue, format: "currency", compact: true },
239
- ]}
240
- />
241
-
242
- {/* layered filter band — the hot dimension (type) inline as chips,
243
- secondary dimensions collapsed into FilterChips so the band stays
244
- scannable as more facets are added */}
245
- <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
246
- <ChipGroup
247
- accessibilityLabel="Request type"
248
- options={[
249
- { label: `All · ${counts("all")}`, value: "all" },
250
- { label: `Purchases · ${counts("purchase")}`, value: "purchase" },
251
- { label: `Discounts · ${counts("discount")}`, value: "discount" },
252
- { label: `Expenses · ${counts("expense")}`, value: "expense" },
253
- ]}
254
- value={tab}
255
- onValueChange={(t) => { setTab(t); setOpenId(null); }}
256
- />
257
- <FilterChip label="Requester" summary={reqSummary} onClear={() => setReqSel([])} clearLabel="Clear requester filter">
258
- <OptionList search={{ mode: "none" }} multi options={REQUESTERS} value={reqSel} onValueChange={setReqSel} />
259
- </FilterChip>
260
- <FilterChip label="Role" summary={roleSummary} onClear={() => setRoleSel([])} clearLabel="Clear role filter">
261
- <OptionList search={{ mode: "none" }} multi options={ROLES} value={roleSel} onValueChange={setRoleSel} />
262
- </FilterChip>
263
- <FilterChip label="Amount" summary={amtSummary} onClear={() => setAmt([AMT_MIN, AMT_MAX])} clearLabel="Clear amount filter">
264
- <RangeSlider
265
- min={AMT_MIN}
266
- max={AMT_MAX}
267
- step={500_000}
268
- value={amt}
269
- onValueChange={setAmt}
270
- format={fmtAmt}
271
- accessibilityLabel="Amount"
272
- />
273
- </FilterChip>
274
- <FilterChip label="Waiting" summary={daysSummary} onClear={() => setMinDays(0)} clearLabel="Clear waiting filter">
275
- <View style={{ gap: 10 }}>
276
- <Text size="sm" color="muted">Waiting at least</Text>
277
- <Counter
278
- value={minDays}
279
- min={0}
280
- max={14}
281
- onValueChange={setMinDays}
282
- accessibilityLabel="minimum days waiting"
283
- format={(n) => `${n} ${n === 1 ? "day" : "days"}`}
284
- />
285
- </View>
286
- </FilterChip>
287
- </View>
288
-
289
- <Card style={{ padding: 0 }}>
290
- <CardHeader>
291
- <CheckboxInput
292
- accessibilityLabel="Select all in-policy requests"
293
- checked={allRoutineSelected}
294
- indeterminate={!allRoutineSelected && routineVisible.some((r) => selected.has(r.id))}
295
- onChange={(on) => setSelected(on ? new Set([...selected, ...routineVisible.map((r) => r.id)]) : new Set([...selected].filter((id) => !routineVisible.some((r) => r.id === id))))}
296
- disabled={routineVisible.length === 0}
297
- />
298
- <CardHeaderTitle info="Press a row to open the request; tick in-policy rows to approve in bulk. Over-policy rows have no checkbox — they must be opened.">
299
- Inbox
300
- </CardHeaderTitle>
301
- </CardHeader>
302
- {visible.length === 0 ? (
303
- <EmptyState message="Inbox zero" hint="Nothing awaits your decision in this view" />
304
- ) : null}
305
- {visible.map((r, i) => (
306
- <View key={r.id}>
307
- {i > 0 ? <Divider /> : null}
308
- <PressableRow onPress={() => setOpenId(r.id)} selected={open?.id === r.id} marked={selected.has(r.id)}>
309
- {overPolicy(r) ? (
310
- // matches the 24px Checkbox footprint so rows stay aligned
311
- <View style={{ width: 24 }} />
312
- ) : (
313
- <CheckboxInput
314
- accessibilityLabel={`Select ${r.id}`}
315
- checked={selected.has(r.id)}
316
- onChange={(on) => setSelected((prev) => { const next = new Set(prev); if (on) next.add(r.id); else next.delete(r.id); return next; })}
317
- />
318
- )}
319
- <Pressable
320
- accessibilityRole="button"
321
- accessibilityLabel={`Open request ${r.id}`}
322
- onPress={() => setOpenId(r.id)}
323
- style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 52 }}
324
- >
325
- <Avatar name={r.requester} size={28} />
326
- <View style={{ flex: 1, gap: 0 }}>
327
- <Text size="sm" numberOfLines={1}>{r.summary}</Text>
328
- <Text size="xs" color="muted">{`${r.requester} · ${TYPE_LABEL[r.type]} · ${r.ageDays}d waiting`}</Text>
329
- </View>
330
- <Text size="sm" tabular align="right" style={{ width: 104 }}>{formatMoney(r.amount)}</Text>
331
- <View style={{ width: 104 }}>
332
- {overPolicy(r) ? <Badge variant="dot" label={`Over +${overPct(r)}%`} color="red" /> : <Badge variant="dot" label="In policy" color="emerald" />}
333
- </View>
334
- </Pressable>
335
- {/* routine verdict = one click; the exception path lives in the drawer.
336
- Fixed-width keeps the trailing column constant so the amount +
337
- status columns line up across in-policy and over-policy rows. */}
338
- {overPolicy(r) ? (
339
- <Button title="Review" color="muted" onPress={() => setOpenId(r.id)} style={{ width: 96 }} />
340
- ) : (
341
- <Button title="Approve" color="secondary" onPress={() => decide([r.id])} style={{ width: 96 }} />
342
- )}
343
- </PressableRow>
344
- </View>
345
- ))}
346
- <CardFooter>
347
- <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
348
- {visible.length === 0 ? "Inbox zero — nothing awaits your decision" : `${visible.length} requests · approving an over-policy request is always recorded as an override`}
349
- </Text>
350
- </CardFooter>
351
- </Card>
352
- </View>
353
- </ScrollView>
354
-
355
- {/* bulk-approve the ticked in-policy requests */}
356
- <FloatingActionBar count={selected.size} label="in-policy requests" onClear={() => setSelected(new Set())}>
357
- <Button title={`Approve ${selected.size}`} color="primary" onPress={() => decide([...selected])} />
358
- </FloatingActionBar>
359
-
360
- {open !== null ? (
361
- <Drawer
362
- open
363
- onOpenChange={(o) => !o && setOpenId(null)}
364
- title={open.id}
365
- width={460}
366
- onPrev={seqIndex > 0 ? () => setOpenId(visible[seqIndex - 1].id) : undefined}
367
- onNext={seqIndex >= 0 && seqIndex < visible.length - 1 ? () => setOpenId(visible[seqIndex + 1].id) : undefined}
368
- position={seqIndex >= 0 ? `${seqIndex + 1}/${visible.length}` : undefined}
369
- >
370
- <RequestWorkspace key={open.id} r={open} onDecide={() => decide([open.id])} />
371
- </Drawer>
372
- ) : null}
373
- </View>
374
- );
375
- }
@@ -1,234 +0,0 @@
1
- import { useState } from "react";
2
- import { Pressable, ScrollView, View } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { colors } from "@lotics/ui/colors";
5
- import { Badge } from "@lotics/ui/badge";
6
- import { Button } from "@lotics/ui/button";
7
- import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
8
- import { CheckboxInput } from "@lotics/ui/checkbox_input";
9
- import { DetailRow } from "@lotics/ui/detail_row";
10
- import { Divider } from "@lotics/ui/divider";
11
- import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
12
- import { EmptyState } from "@lotics/ui/empty_state";
13
- import { IconButton } from "@lotics/ui/icon_button";
14
- import { KPIStrip } from "@lotics/ui/kpi_strip";
15
- import { PressableRow } from "@lotics/ui/pressable_row";
16
- import { formatMoney } from "@lotics/ui/format_money";
17
-
18
- // ─────────────────────────────────────────────────────────────────────────────
19
- // Template · Batch builder — the work COMPOSES a new record from parts:
20
- // tick source rows, watch the draft build with running totals, create the
21
- // aggregate (here: uninvoiced deliveries → one invoice; same shape for
22
- // payment runs, shipment consolidation, PO bundling). The grouping rule —
23
- // one invoice, one customer — is enforced at the checkbox: once the first
24
- // row is ticked, other customers' rows disable with the reason visible.
25
- // The draft panel is the live preview; Create resolves the batch and the
26
- // source rows leave the pool.
27
- // ─────────────────────────────────────────────────────────────────────────────
28
-
29
- interface Delivery {
30
- id: string;
31
- customer: string;
32
- description: string;
33
- date: string;
34
- ageDays: number;
35
- amount: number;
36
- }
37
-
38
- const DELIVERIES: Delivery[] = [
39
- { id: "DL-0214", customer: "ATLAS COMPONENTS", description: "Carton blank sheet 675×325 — 2,747 pcs", date: "27/05", ageDays: 16, amount: 8_982_690 },
40
- { id: "DL-0218", customer: "ATLAS COMPONENTS", description: "Full cover box 1470×290×168 — 580 pcs", date: "02/06", ageDays: 10, amount: 31_414_670 },
41
- { id: "DL-0223", customer: "ATLAS COMPONENTS", description: "Full cover box 1470×290×168 — 582 pcs", date: "09/06", ageDays: 3, amount: 31_414_670 },
42
- { id: "DL-0216", customer: "CRESTLINE FURNITURE", description: "TOP COVER long brace — 2,500 sets", date: "30/05", ageDays: 13, amount: 2_000_000 },
43
- { id: "DL-0221", customer: "CRESTLINE FURNITURE", description: "TOP COVER long brace — 2,500 sets", date: "06/06", ageDays: 6, amount: 2_000_000 },
44
- { id: "DL-0219", customer: "KING LUN PLASTICS", description: "Inner box VTD063518 — 3,000 pcs", date: "04/06", ageDays: 8, amount: 23_400_000 },
45
- { id: "DL-0224", customer: "VITTORIA ACCESSORIES", description: "Accessory carton 400×300×250 — 1,250 pcs", date: "10/06", ageDays: 2, amount: 11_250_000 },
46
- { id: "DL-0225", customer: "VITTORIA ACCESSORIES", description: "Accessory carton 400×300×250 — 1,250 pcs", date: "11/06", ageDays: 1, amount: 11_250_000 },
47
- ];
48
-
49
- const VAT_RATE = 0.08;
50
-
51
- export function TplBatch() {
52
- const [pool, setPool] = useState<Delivery[]>(DELIVERIES);
53
- const [selected, setSelected] = useState<Set<string>>(new Set());
54
- const [openId, setOpenId] = useState<string | null>(null);
55
-
56
- const picked = pool.filter((d) => selected.has(d.id));
57
- // The grouping rule: the first ticked row locks the batch to its customer.
58
- const lockedCustomer = picked[0]?.customer ?? null;
59
-
60
- const toggle = (id: string, on: boolean) =>
61
- setSelected((prev) => { const next = new Set(prev); if (on) next.add(id); else next.delete(id); return next; });
62
-
63
- const customers = [...new Set(pool.map((d) => d.customer))];
64
- const subtotal = picked.reduce((s, d) => s + d.amount, 0);
65
- const vat = Math.round(subtotal * VAT_RATE);
66
- const oldest = pool.reduce((m, d) => Math.max(m, d.ageDays), 0);
67
-
68
- const create = () => {
69
- setPool((prev) => prev.filter((d) => !selected.has(d.id)));
70
- setSelected(new Set());
71
- setOpenId(null);
72
- };
73
-
74
- const open = pool.find((d) => d.id === openId) ?? null;
75
- const seqIndex = open ? pool.findIndex((d) => d.id === open.id) : -1;
76
-
77
- return (
78
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
79
- <View style={{ width: "100%", maxWidth: 1060, alignSelf: "center", gap: 16 }}>
80
- {/* header band */}
81
- <View style={{ gap: 2 }}>
82
- <Text size="xl" weight="semibold">Invoice builder</Text>
83
- <Text size="sm" color="muted">Tick deliveries, watch the draft build, create one invoice — one customer per batch</Text>
84
- </View>
85
-
86
- <KPIStrip
87
- items={[
88
- { label: "Uninvoiced deliveries", value: pool.length, format: "number" },
89
- { label: "Value uninvoiced", value: pool.reduce((s, d) => s + d.amount, 0), format: "currency", compact: true, info: "Goods delivered but not yet billed — every day here is working capital lent to the customer for free." },
90
- { label: "Customers awaiting", value: customers.length, format: "number", caption: "one invoice each" },
91
- { label: "Oldest delivery", value: oldest, format: "number", tone: oldest > 14 ? "danger" : "default", caption: "days unbilled" },
92
- ]}
93
- />
94
-
95
- <View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
96
- {/* the source pool, grouped by the batching key */}
97
- <Card style={{ padding: 0, flexGrow: 2, flexBasis: 540 }}>
98
- <CardHeader>
99
- <CardHeaderTitle info="One invoice covers one customer: the first ticked row locks the batch, and other customers' rows disable until you clear it.">
100
- Uninvoiced deliveries
101
- </CardHeaderTitle>
102
- </CardHeader>
103
- {pool.length === 0 ? (
104
- <EmptyState message="Everything is billed" hint="The pool refills as deliveries complete" />
105
- ) : (
106
- customers.map((customer) => {
107
- const rows = pool.filter((d) => d.customer === customer);
108
- const blocked = lockedCustomer !== null && lockedCustomer !== customer;
109
- const allPicked = rows.every((d) => selected.has(d.id));
110
- return (
111
- <View key={customer}>
112
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 20, paddingTop: 14, paddingBottom: 6 }}>
113
- <CheckboxInput
114
- accessibilityLabel={`Select all deliveries for ${customer}`}
115
- checked={allPicked && rows.length > 0}
116
- indeterminate={!allPicked && rows.some((d) => selected.has(d.id))}
117
- disabled={blocked}
118
- onChange={(on) => setSelected((prev) => {
119
- const next = new Set(prev);
120
- rows.forEach((d) => { if (on) next.add(d.id); else next.delete(d.id); });
121
- return next;
122
- })}
123
- />
124
- <Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>{customer}</Text>
125
- {blocked ? <Text size="xs" color="muted">{`batch locked to ${lockedCustomer}`}</Text> : null}
126
- </View>
127
- <View style={{ paddingHorizontal: 20, paddingBottom: 10, gap: 2 }}>
128
- {rows.map((d) => (
129
- <PressableRow key={d.id} variant="inset" marked={selected.has(d.id)} onPress={() => setOpenId(d.id)} selected={open?.id === d.id} style={{ gap: 10 }}>
130
- <CheckboxInput
131
- accessibilityLabel={`Select ${d.id}`}
132
- checked={selected.has(d.id)}
133
- disabled={blocked}
134
- onChange={(on) => toggle(d.id, on)}
135
- />
136
- <Pressable
137
- accessibilityRole="button"
138
- accessibilityLabel={`Open delivery ${d.id}`}
139
- onPress={() => setOpenId(d.id)}
140
- style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 44 }}
141
- >
142
- <Text size="sm" weight="medium" tabular style={{ width: 70 }}>{d.id}</Text>
143
- <Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{d.description}</Text>
144
- <Text size="sm" color={d.ageDays > 14 ? "danger" : "muted"} tabular style={{ width: 44 }}>{`${d.ageDays}d`}</Text>
145
- <Text size="sm" tabular align="right" style={{ width: 104 }}>{formatMoney(d.amount)}</Text>
146
- </Pressable>
147
- </PressableRow>
148
- ))}
149
- </View>
150
- </View>
151
- );
152
- })
153
- )}
154
- <CardFooter>
155
- <Text size="xs" color="muted" tabular style={{ flex: 1 }}>
156
- {`${pool.length} deliveries · ${formatMoney(pool.reduce((s, d) => s + d.amount, 0), { compact: true })} unbilled`}
157
- </Text>
158
- </CardFooter>
159
- </Card>
160
-
161
- {/* the draft — a live preview of the record being composed */}
162
- <Card style={{ padding: 0, flexGrow: 1, flexBasis: 320 }}>
163
- <CardHeader>
164
- <CardHeaderTitle info="The invoice being composed — lines, totals and VAT update as you tick. Create resolves the batch; the deliveries leave the pool.">
165
- Draft invoice
166
- </CardHeaderTitle>
167
- </CardHeader>
168
- {picked.length === 0 ? (
169
- <EmptyState message="No lines yet" hint="Tick deliveries on the left to start the draft" />
170
- ) : (
171
- <>
172
- <View style={{ paddingHorizontal: 20, paddingVertical: 14, gap: 10 }}>
173
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
174
- <Badge label={lockedCustomer ?? ""} color="blue" />
175
- <Text size="xs" color="muted" tabular>{`${picked.length} lines`}</Text>
176
- </View>
177
- <View style={{ gap: 4 }}>
178
- {picked.map((d) => (
179
- <View key={d.id} style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 28 }}>
180
- <Text size="sm" color="muted" tabular style={{ width: 70 }}>{d.id}</Text>
181
- <Text size="xs" color="muted" numberOfLines={1} style={{ flex: 1 }}>{d.date}</Text>
182
- <Text size="sm" tabular align="right">{formatMoney(d.amount)}</Text>
183
- <IconButton icon="x" accessibilityLabel={`Remove ${d.id} from the draft`} onPress={() => toggle(d.id, false)} />
184
- </View>
185
- ))}
186
- </View>
187
- <Divider />
188
- <DetailRow label="Subtotal"><Text size="sm" tabular>{formatMoney(subtotal)}</Text></DetailRow>
189
- <DetailRow label={`VAT ${Math.round(VAT_RATE * 100)}%`}><Text size="sm" tabular>{formatMoney(vat)}</Text></DetailRow>
190
- <Divider />
191
- <DetailRow label="Total"><Text size="md" weight="semibold" tabular>{formatMoney(subtotal + vat)}</Text></DetailRow>
192
- </View>
193
- <CardFooter>
194
- <Button title="Clear" color="muted" onPress={() => setSelected(new Set())} />
195
- <View style={{ flex: 1 }} />
196
- <Button title="Create invoice" color="primary" onPress={create} />
197
- </CardFooter>
198
- </>
199
- )}
200
- </Card>
201
- </View>
202
- </View>
203
-
204
- {open !== null ? (
205
- <Drawer
206
- open
207
- onOpenChange={(o) => !o && setOpenId(null)}
208
- title={open.id}
209
- width={440}
210
- onPrev={seqIndex > 0 ? () => setOpenId(pool[seqIndex - 1].id) : undefined}
211
- onNext={seqIndex >= 0 && seqIndex < pool.length - 1 ? () => setOpenId(pool[seqIndex + 1].id) : undefined}
212
- position={seqIndex >= 0 ? `${seqIndex + 1}/${pool.length}` : undefined}
213
- >
214
- <View key={open.id} style={{ flex: 1, padding: 24, gap: 14 }}>
215
- <Badge label={open.customer} color="blue" />
216
- <Text size="sm">{open.description}</Text>
217
- <View style={{ gap: 6 }}>
218
- <DetailRow label="Delivered"><Text size="sm" tabular>{open.date}</Text></DetailRow>
219
- <Divider />
220
- <DetailRow label="Days unbilled">
221
- <Text size="sm" tabular color={open.ageDays > 14 ? "danger" : "default"}>{`${open.ageDays} days`}</Text>
222
- </DetailRow>
223
- <Divider />
224
- <DetailRow label="Amount"><Text size="sm" tabular>{formatMoney(open.amount)}</Text></DetailRow>
225
- </View>
226
- </View>
227
- <DrawerFooter>
228
- <Button title="Open delivery record" color="primary" onPress={() => {}} />
229
- </DrawerFooter>
230
- </Drawer>
231
- ) : null}
232
- </ScrollView>
233
- );
234
- }