@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.
- package/AGENTS.md +323 -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_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 +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/callout.tsx +50 -17
- package/src/combobox.tsx +22 -6
- 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/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -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/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- 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
|
+
}
|