@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,375 @@
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 { FilterPill, selectSummary } from "@lotics/ui/filter_pill";
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 { PickerMenu } from "@lotics/ui/picker_menu";
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 FilterPills 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
+ <FilterPill label="Requester" summary={reqSummary} onClear={() => setReqSel([])} clearLabel="Clear requester filter">
258
+ <PickerMenu multi enableSelectAll options={REQUESTERS} value={reqSel} onValueChange={setReqSel} />
259
+ </FilterPill>
260
+ <FilterPill label="Role" summary={roleSummary} onClear={() => setRoleSel([])} clearLabel="Clear role filter">
261
+ <PickerMenu multi options={ROLES} value={roleSel} onValueChange={setRoleSel} />
262
+ </FilterPill>
263
+ <FilterPill 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
+ </FilterPill>
274
+ <FilterPill 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
+ </FilterPill>
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
+ }