@lotics/ui 7.7.0 → 7.9.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 +75 -33
- package/examples/tpl_allocate.tsx +6 -6
- package/examples/tpl_assistant.tsx +1 -1
- package/examples/tpl_attendance.tsx +1 -1
- package/examples/tpl_billing.tsx +2 -2
- package/examples/tpl_briefing.tsx +1 -1
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_compare.tsx +1 -1
- package/examples/tpl_crosscheck.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_detail.tsx +1 -1
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_directory.tsx +5 -8
- package/examples/tpl_draft.tsx +2 -2
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_intake.tsx +1 -1
- package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
- package/examples/tpl_lookup.tsx +2 -2
- package/examples/tpl_match.tsx +2 -2
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pick.tsx +64 -68
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_quick.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +1 -1
- package/examples/tpl_record_plain.tsx +1 -1
- package/examples/tpl_report.tsx +1 -1
- package/examples/tpl_rollup.tsx +1 -1
- package/examples/tpl_settings.tsx +1 -1
- package/examples/tpl_shifts.tsx +1 -1
- package/examples/tpl_stock.tsx +1 -1
- package/examples/tpl_task_board.tsx +2 -2
- package/examples/tpl_tasks.tsx +1 -1
- package/examples/tpl_timeline.tsx +1 -1
- package/examples/tpl_tower.tsx +1 -1
- package/examples/tpl_triage.tsx +1 -1
- package/examples/tpl_wizard.tsx +1 -1
- package/package.json +3 -1
- package/src/card.tsx +6 -13
- package/src/combobox.tsx +28 -11
- package/src/data_grid.tsx +1 -1
- package/src/kpi_strip.tsx +5 -5
- package/src/pressable_row.tsx +22 -10
- package/src/sort_header.tsx +9 -4
- package/src/summary_line.tsx +76 -0
- package/src/table.tsx +27 -11
- package/src/use_selection.test.ts +66 -0
- package/src/use_selection.ts +75 -0
- package/examples/app_orders.tsx +0 -405
- package/examples/tpl_approvals.tsx +0 -375
- package/examples/tpl_batch.tsx +0 -234
- package/examples/tpl_callsheet.tsx +0 -481
- package/examples/tpl_convert.tsx +0 -478
- package/examples/tpl_crm_desk.tsx +0 -541
- package/examples/tpl_dispatch.tsx +0 -289
- package/examples/tpl_inventory.tsx +0 -299
- package/examples/tpl_pipeline.tsx +0 -400
- package/examples/tpl_reconcile.tsx +0 -275
- package/examples/tpl_run.tsx +0 -235
|
@@ -1,275 +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 { 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, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
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 { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
14
|
-
import { MenuListItem } from "@lotics/ui/menu_list_item";
|
|
15
|
-
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
16
|
-
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
17
|
-
import { formatMoney } from "@lotics/ui/format_money";
|
|
18
|
-
|
|
19
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
-
// Template · Reconciliation — the unit of work is a PAIR: two lists that
|
|
21
|
-
// must agree (here: bank statement lines vs open invoices; same shape for
|
|
22
|
-
// 3-way match, delivered-vs-ordered, ledger-vs-subledger). The engine
|
|
23
|
-
// proposes matches with a named confidence; the person confirms in one
|
|
24
|
-
// click, demotes wrong guesses, and resolves the unmatched tail in the
|
|
25
|
-
// drawer — pick a candidate, or take an escape hatch (bank fee, manual
|
|
26
|
-
// receipt). Filter pills are the match lifecycle; confirming moves rows live.
|
|
27
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
interface Invoice {
|
|
30
|
-
id: string;
|
|
31
|
-
customer: string;
|
|
32
|
-
amount: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface BankLine {
|
|
36
|
-
id: string;
|
|
37
|
-
date: string;
|
|
38
|
-
desc: string;
|
|
39
|
-
amount: number;
|
|
40
|
-
status: "suggested" | "unmatched" | "confirmed";
|
|
41
|
-
/** The engine's proposed invoice + why it believes the pair. */
|
|
42
|
-
match?: { invoice: string; confidence: "Exact reference" | "Amount + date" };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const INVOICES: Invoice[] = [
|
|
46
|
-
{ id: "INV-2026-0312", customer: "ATLAS COMPONENTS", amount: 86_200_000 },
|
|
47
|
-
{ id: "INV-2026-0318", customer: "CRESTLINE FURNITURE", amount: 41_300_000 },
|
|
48
|
-
{ id: "INV-2026-0320", customer: "KING LUN PLASTICS", amount: 23_400_000 },
|
|
49
|
-
{ id: "INV-2026-0321", customer: "VITTORIA ACCESSORIES", amount: 22_500_000 },
|
|
50
|
-
{ id: "INV-2026-0324", customer: "BRIGHTCELL BATTERIES", amount: 18_700_000 },
|
|
51
|
-
{ id: "INV-2026-0326", customer: "MERIDIAN CONSTRUCTION", amount: 25_200_000 },
|
|
52
|
-
{ id: "INV-2026-0327", customer: "KING LUN PLASTICS", amount: 23_900_000 },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
const LINES: BankLine[] = [
|
|
56
|
-
{ id: "BL-091", date: "09/06", desc: "ATLAS COMPONENTS — INV-2026-0312", amount: 86_200_000, status: "suggested", match: { invoice: "INV-2026-0312", confidence: "Exact reference" } },
|
|
57
|
-
{ id: "BL-092", date: "09/06", desc: "CRESTLINE TT PAYMENT", amount: 41_300_000, status: "suggested", match: { invoice: "INV-2026-0318", confidence: "Amount + date" } },
|
|
58
|
-
{ id: "BL-094", date: "10/06", desc: "VITTORIA ACC — INV-2026-0321", amount: 22_500_000, status: "suggested", match: { invoice: "INV-2026-0321", confidence: "Exact reference" } },
|
|
59
|
-
{ id: "BL-095", date: "10/06", desc: "BRIGHTCELL JUNE", amount: 18_700_000, status: "suggested", match: { invoice: "INV-2026-0324", confidence: "Amount + date" } },
|
|
60
|
-
{ id: "BL-096", date: "11/06", desc: "KING LUN PLASTICS TRANSFER", amount: 23_400_000, status: "unmatched" },
|
|
61
|
-
{ id: "BL-097", date: "11/06", desc: "INCOMING TT REF 88412", amount: 25_200_000, status: "unmatched" },
|
|
62
|
-
{ id: "BL-098", date: "11/06", desc: "BANK CHARGES JUNE", amount: 264_000, status: "unmatched" },
|
|
63
|
-
{ id: "BL-099", date: "12/06", desc: "MERIDIAN PART PAYMENT", amount: 12_600_000, status: "unmatched" },
|
|
64
|
-
{ id: "BL-088", date: "08/06", desc: "ATLAS COMPONENTS — INV-2026-0301", amount: 64_800_000, status: "confirmed", match: { invoice: "INV-2026-0301", confidence: "Exact reference" } },
|
|
65
|
-
{ id: "BL-089", date: "08/06", desc: "NORTHWIND PKG — INV-2026-0305", amount: 31_400_000, status: "confirmed", match: { invoice: "INV-2026-0305", confidence: "Exact reference" } },
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
const invoiceOf = (id: string) => INVOICES.find((i) => i.id === id);
|
|
69
|
-
|
|
70
|
-
/** Candidate invoices for an unmatched line — closest amounts first. */
|
|
71
|
-
const candidatesFor = (line: BankLine, taken: Set<string>) =>
|
|
72
|
-
INVOICES.filter((i) => !taken.has(i.id))
|
|
73
|
-
.map((i) => ({ ...i, delta: i.amount - line.amount }))
|
|
74
|
-
.sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta))
|
|
75
|
-
.slice(0, 3);
|
|
76
|
-
|
|
77
|
-
function SideCell({ top, bottom, width }: { top: string; bottom: string; width?: number }) {
|
|
78
|
-
return (
|
|
79
|
-
<View style={{ gap: 0, width, flexShrink: 1 }}>
|
|
80
|
-
<Text size="sm" numberOfLines={1}>{top}</Text>
|
|
81
|
-
<Text size="xs" color="muted" numberOfLines={1}>{bottom}</Text>
|
|
82
|
-
</View>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function TplReconcile() {
|
|
87
|
-
const [lines, setLines] = useState<BankLine[]>(LINES);
|
|
88
|
-
const [tab, setTab] = useState("suggested");
|
|
89
|
-
const [openId, setOpenId] = useState<string | null>(null);
|
|
90
|
-
|
|
91
|
-
const setStatus = (id: string, status: BankLine["status"], match?: BankLine["match"]) => {
|
|
92
|
-
setLines((prev) => prev.map((l) => (l.id === id ? { ...l, status, match: match ?? (status === "unmatched" ? undefined : l.match) } : l)));
|
|
93
|
-
setOpenId(null);
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const byStatus = (s: BankLine["status"]) => lines.filter((l) => l.status === s);
|
|
97
|
-
const visible = byStatus(tab as BankLine["status"]);
|
|
98
|
-
const open = lines.find((l) => l.id === openId) ?? null;
|
|
99
|
-
const seqIndex = open ? visible.findIndex((l) => l.id === open.id) : -1;
|
|
100
|
-
|
|
101
|
-
const unexplained = byStatus("unmatched").reduce((s, l) => s + l.amount, 0);
|
|
102
|
-
const matchedValue = byStatus("confirmed").reduce((s, l) => s + l.amount, 0);
|
|
103
|
-
const takenInvoices = new Set(lines.filter((l) => l.match && l.status !== "unmatched").map((l) => l.match!.invoice));
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
107
|
-
<View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
|
|
108
|
-
{/* header band */}
|
|
109
|
-
<View style={{ gap: 2 }}>
|
|
110
|
-
<Text size="xl" weight="semibold">Payment reconciliation</Text>
|
|
111
|
-
<Text size="sm" color="muted">Two lists that must agree — confirm the engine's matches, then chase the unexplained tail</Text>
|
|
112
|
-
</View>
|
|
113
|
-
|
|
114
|
-
<KPIStrip
|
|
115
|
-
items={[
|
|
116
|
-
{ label: "Statement lines", value: lines.length, format: "number", caption: "this period" },
|
|
117
|
-
{ label: "Open invoices", value: INVOICES.length - takenInvoices.size, format: "number", info: "Invoices with no matching statement line yet — the other half of the reconciliation." },
|
|
118
|
-
{ label: "Matched value", value: matchedValue, format: "currency", compact: true, info: "Statement money tied to an invoice — the explained half of the period." },
|
|
119
|
-
{ label: "Unexplained", value: unexplained, format: "currency", compact: true, tone: unexplained > 0 ? "danger" : "default", info: "Money on the statement not yet tied to an invoice or an escape hatch (bank fee, manual receipt). Reconciliation closes at zero." },
|
|
120
|
-
]}
|
|
121
|
-
/>
|
|
122
|
-
|
|
123
|
-
<ChipGroup
|
|
124
|
-
accessibilityLabel="Match lifecycle"
|
|
125
|
-
options={[
|
|
126
|
-
{ label: `Suggested · ${byStatus("suggested").length}`, value: "suggested" },
|
|
127
|
-
{ label: `Unmatched · ${byStatus("unmatched").length}`, value: "unmatched" },
|
|
128
|
-
{ label: `Confirmed · ${byStatus("confirmed").length}`, value: "confirmed" },
|
|
129
|
-
]}
|
|
130
|
-
value={tab}
|
|
131
|
-
onValueChange={(t) => { setTab(t); setOpenId(null); }}
|
|
132
|
-
/>
|
|
133
|
-
|
|
134
|
-
<Card style={{ padding: 0 }}>
|
|
135
|
-
<CardHeader>
|
|
136
|
-
<CardHeaderTitle
|
|
137
|
-
info={
|
|
138
|
-
tab === "suggested"
|
|
139
|
-
? "Bank line on the left, proposed invoice on the right, the engine's reason in between. Confirm is one click; ⋯ demotes a wrong guess."
|
|
140
|
-
: tab === "unmatched"
|
|
141
|
-
? "Statement money with no invoice yet. Press a row to pick from candidates or take an escape hatch."
|
|
142
|
-
: "Settled pairs. ⋯ unlinks a mistake — the line returns to Unmatched."
|
|
143
|
-
}
|
|
144
|
-
>
|
|
145
|
-
{tab === "suggested" ? "Proposed matches" : tab === "unmatched" ? "Needs a home" : "Settled"}
|
|
146
|
-
</CardHeaderTitle>
|
|
147
|
-
</CardHeader>
|
|
148
|
-
|
|
149
|
-
{visible.length === 0 ? (
|
|
150
|
-
<EmptyState message={tab === "unmatched" ? "Nothing unexplained" : "Nothing here"} hint={tab === "suggested" ? "Every proposal is confirmed or demoted" : undefined} />
|
|
151
|
-
) : (
|
|
152
|
-
visible.map((l, i) => {
|
|
153
|
-
const inv = l.match ? invoiceOf(l.match.invoice) : undefined;
|
|
154
|
-
return (
|
|
155
|
-
<View key={l.id}>
|
|
156
|
-
{i > 0 ? <Divider /> : null}
|
|
157
|
-
<PressableRow onPress={() => setOpenId(l.id)} selected={open?.id === l.id}>
|
|
158
|
-
<Pressable
|
|
159
|
-
accessibilityRole="button"
|
|
160
|
-
accessibilityLabel={`Open statement line ${l.id}`}
|
|
161
|
-
onPress={() => setOpenId(l.id)}
|
|
162
|
-
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 56 }}
|
|
163
|
-
>
|
|
164
|
-
{/* bank side */}
|
|
165
|
-
<Text size="sm" color="muted" tabular style={{ width: 44 }}>{l.date}</Text>
|
|
166
|
-
<SideCell top={l.desc} bottom={`${l.id} · ${formatMoney(l.amount)}`} />
|
|
167
|
-
<View style={{ flex: 1 }} />
|
|
168
|
-
{/* the pair bridge */}
|
|
169
|
-
{l.match ? (
|
|
170
|
-
<>
|
|
171
|
-
<Badge variant="dot" label={l.match.confidence} color={l.match.confidence === "Exact reference" ? "emerald" : "amber"} />
|
|
172
|
-
<SideCell
|
|
173
|
-
top={l.match.invoice}
|
|
174
|
-
bottom={inv ? `${inv.customer} · ${formatMoney(inv.amount)}` : "non-invoice entry"}
|
|
175
|
-
width={210}
|
|
176
|
-
/>
|
|
177
|
-
</>
|
|
178
|
-
) : (
|
|
179
|
-
<Badge variant="dot" label="No invoice" color="red" />
|
|
180
|
-
)}
|
|
181
|
-
</Pressable>
|
|
182
|
-
{l.status === "suggested" ? (
|
|
183
|
-
<>
|
|
184
|
-
<Button title="Confirm" color="secondary" onPress={() => setStatus(l.id, "confirmed")} />
|
|
185
|
-
<ActionMenu
|
|
186
|
-
accessibilityLabel={`Actions for ${l.id}`}
|
|
187
|
-
items={[{ key: "demote", label: "Not a match", icon: "ban", danger: true, onPress: () => Alert.alert("Demote this match?", "The proposed match moves back to the unexplained list.", [{ text: "Keep", style: "cancel" }, { text: "Not a match", style: "destructive", onPress: () => setStatus(l.id, "unmatched") }]) }]}
|
|
188
|
-
/>
|
|
189
|
-
</>
|
|
190
|
-
) : null}
|
|
191
|
-
{l.status === "unmatched" ? (
|
|
192
|
-
<Button title="Resolve" color="secondary" onPress={() => setOpenId(l.id)} />
|
|
193
|
-
) : null}
|
|
194
|
-
{l.status === "confirmed" ? (
|
|
195
|
-
<ActionMenu
|
|
196
|
-
accessibilityLabel={`Actions for ${l.id}`}
|
|
197
|
-
items={[{ key: "unlink", label: "Unlink match", icon: "ban", danger: true, onPress: () => Alert.alert("Unlink this match?", "The confirmed pair is broken and the line returns to unexplained.", [{ text: "Keep", style: "cancel" }, { text: "Unlink", style: "destructive", onPress: () => setStatus(l.id, "unmatched") }]) }]}
|
|
198
|
-
/>
|
|
199
|
-
) : null}
|
|
200
|
-
</PressableRow>
|
|
201
|
-
</View>
|
|
202
|
-
);
|
|
203
|
-
})
|
|
204
|
-
)}
|
|
205
|
-
|
|
206
|
-
<CardFooter>
|
|
207
|
-
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>
|
|
208
|
-
{`${visible.length} lines · ${formatMoney(visible.reduce((s, l) => s + l.amount, 0), { compact: true })} in this view`}
|
|
209
|
-
</Text>
|
|
210
|
-
</CardFooter>
|
|
211
|
-
</Card>
|
|
212
|
-
</View>
|
|
213
|
-
|
|
214
|
-
{open !== null ? (
|
|
215
|
-
<Drawer
|
|
216
|
-
open
|
|
217
|
-
onOpenChange={(o) => !o && setOpenId(null)}
|
|
218
|
-
title={open.id}
|
|
219
|
-
width={460}
|
|
220
|
-
onPrev={seqIndex > 0 ? () => setOpenId(visible[seqIndex - 1].id) : undefined}
|
|
221
|
-
onNext={seqIndex >= 0 && seqIndex < visible.length - 1 ? () => setOpenId(visible[seqIndex + 1].id) : undefined}
|
|
222
|
-
position={seqIndex >= 0 ? `${seqIndex + 1}/${visible.length}` : undefined}
|
|
223
|
-
>
|
|
224
|
-
<ScrollView key={open.id} style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 14 }}>
|
|
225
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
226
|
-
<Badge label={open.status === "confirmed" ? "Settled" : open.status === "suggested" ? "Proposed" : "Unmatched"} color={open.status === "confirmed" ? "emerald" : open.status === "suggested" ? "amber" : "red"} />
|
|
227
|
-
<Text size="sm" color="muted" tabular>{open.date}</Text>
|
|
228
|
-
</View>
|
|
229
|
-
<Text size="sm">{open.desc}</Text>
|
|
230
|
-
<Text size="lg" weight="semibold" tabular>{formatMoney(open.amount)}</Text>
|
|
231
|
-
<Divider />
|
|
232
|
-
{open.status === "unmatched" ? (
|
|
233
|
-
<View style={{ gap: 8 }}>
|
|
234
|
-
<Text size="sm" weight="semibold">Candidate invoices</Text>
|
|
235
|
-
<View style={{ gap: 2 }}>
|
|
236
|
-
{candidatesFor(open, takenInvoices).map((c) => (
|
|
237
|
-
<MenuListItem
|
|
238
|
-
key={c.id}
|
|
239
|
-
title={`${c.id} · ${c.customer}`}
|
|
240
|
-
description={
|
|
241
|
-
c.delta === 0
|
|
242
|
-
? `${formatMoney(c.amount)} — exact amount`
|
|
243
|
-
: `${formatMoney(c.amount)} — off by ${formatMoney(Math.abs(c.delta))}${c.delta > 0 ? " (partial payment?)" : ""}`
|
|
244
|
-
}
|
|
245
|
-
onPress={() => setStatus(open.id, "confirmed", { invoice: c.id, confidence: "Amount + date" })}
|
|
246
|
-
/>
|
|
247
|
-
))}
|
|
248
|
-
</View>
|
|
249
|
-
<Text size="xs" color="muted">No candidate fits? Take an escape hatch below — unexplained money never stays unexplained.</Text>
|
|
250
|
-
</View>
|
|
251
|
-
) : open.match ? (
|
|
252
|
-
<View style={{ gap: 6 }}>
|
|
253
|
-
<Text size="sm" weight="semibold">Matched invoice</Text>
|
|
254
|
-
<Text size="sm" weight="medium" tabular>{open.match.invoice}</Text>
|
|
255
|
-
<Text size="xs" color="muted">{`${invoiceOf(open.match.invoice)?.customer ?? "Non-invoice entry"} · matched on ${open.match.confidence.toLowerCase()}`}</Text>
|
|
256
|
-
</View>
|
|
257
|
-
) : null}
|
|
258
|
-
</ScrollView>
|
|
259
|
-
<DrawerFooter>
|
|
260
|
-
{open.status === "unmatched" ? (
|
|
261
|
-
<>
|
|
262
|
-
<Button title="Record as bank fee" color="muted" onPress={() => setStatus(open.id, "confirmed", { invoice: "Bank fees · June", confidence: "Exact reference" })} />
|
|
263
|
-
<Button title="Create manual receipt" color="secondary" onPress={() => {}} />
|
|
264
|
-
</>
|
|
265
|
-
) : open.status === "suggested" ? (
|
|
266
|
-
<Button title="Confirm match" color="primary" onPress={() => setStatus(open.id, "confirmed")} />
|
|
267
|
-
) : (
|
|
268
|
-
<Button title="Open receipt record" color="primary" onPress={() => {}} />
|
|
269
|
-
)}
|
|
270
|
-
</DrawerFooter>
|
|
271
|
-
</Drawer>
|
|
272
|
-
) : null}
|
|
273
|
-
</ScrollView>
|
|
274
|
-
);
|
|
275
|
-
}
|
package/examples/tpl_run.tsx
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
|
-
import { Badge } from "@lotics/ui/badge";
|
|
6
|
-
import { Button } from "@lotics/ui/button";
|
|
7
|
-
import { Card, CardFooter } from "@lotics/ui/card";
|
|
8
|
-
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
9
|
-
import { CompletionState } from "@lotics/ui/completion_state";
|
|
10
|
-
import { DetailRow } from "@lotics/ui/detail_row";
|
|
11
|
-
import { Divider } from "@lotics/ui/divider";
|
|
12
|
-
import { Drawer, DrawerFooter } from "@lotics/ui/drawer";
|
|
13
|
-
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
14
|
-
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
15
|
-
import { formatMoney } from "@lotics/ui/format_money";
|
|
16
|
-
|
|
17
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
-
// Template · Run & post — stage a bulk operation, PREVIEW what it will do row by
|
|
19
|
-
// row with a validation verdict, then commit the valid and HOLD the errors. Here:
|
|
20
|
-
// a billing run (generate the period's invoices); the same shape serves a payroll
|
|
21
|
-
// run, period-close postings, an MRP run, depreciation. Each row carries a status
|
|
22
|
-
// — ready · needs review (posts, flagged) · blocked (held) — with the reason
|
|
23
|
-
// inline; blockers are resolved in the drawer, which moves them back into the
|
|
24
|
-
// postable set. The footer "Post" is GATED: you can never post a blocked line, and
|
|
25
|
-
// the run can't apply more than it previews. CompletionState closes the run.
|
|
26
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
type RunStatus = "ready" | "review" | "blocked";
|
|
29
|
-
|
|
30
|
-
interface RunLine {
|
|
31
|
-
id: string;
|
|
32
|
-
customer: string;
|
|
33
|
-
ref: string;
|
|
34
|
-
amount: number;
|
|
35
|
-
status: RunStatus;
|
|
36
|
-
/** Why it's flagged / held — shown inline and in the drawer. */
|
|
37
|
-
issue?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const STATUS: Record<RunStatus, { label: string; color: ColorName }> = {
|
|
41
|
-
ready: { label: "Ready", color: "emerald" },
|
|
42
|
-
review: { label: "Needs review", color: "amber" },
|
|
43
|
-
blocked: { label: "Blocked", color: "red" },
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const LINES: RunLine[] = [
|
|
47
|
-
{ id: "SO-4021", customer: "ATLAS COMPONENTS", ref: "Order SO-4021", amount: 31_414_670, status: "ready" },
|
|
48
|
-
{ id: "SO-4024", customer: "Northwind Packaging", ref: "Order SO-4024", amount: 18_900_000, status: "ready" },
|
|
49
|
-
{ id: "SO-4025", customer: "CRESTLINE FURNITURE", ref: "Order SO-4025", amount: 4_000_000, status: "review", issue: "PO number missing — invoice will post without it" },
|
|
50
|
-
{ id: "SO-4027", customer: "King Lun Plastics", ref: "Order SO-4027", amount: 23_400_000, status: "ready" },
|
|
51
|
-
{ id: "SO-4030", customer: "Vittoria Accessories", ref: "Order SO-4030", amount: 22_500_000, status: "review", issue: "Customer over credit limit by ₫8.4M" },
|
|
52
|
-
{ id: "SO-4031", customer: "Harbor Industries", ref: "Order SO-4031", amount: 13_650_000, status: "blocked", issue: "Customer tax ID missing — cannot issue a tax invoice" },
|
|
53
|
-
{ id: "SO-4033", customer: "Summit Logistics", ref: "Order SO-4033", amount: 9_180_000, status: "ready" },
|
|
54
|
-
{ id: "SO-4035", customer: "Beacon Supplies", ref: "Order SO-4035", amount: 28_800_000, status: "blocked", issue: "Customer on credit hold — release the hold to invoice" },
|
|
55
|
-
{ id: "SO-4036", customer: "Meridian Foods", ref: "Order SO-4036", amount: 16_320_000, status: "ready" },
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
const FILTERS: { value: string; label: string }[] = [
|
|
59
|
-
{ value: "all", label: "All" },
|
|
60
|
-
{ value: "ready", label: "Ready" },
|
|
61
|
-
{ value: "review", label: "Needs review" },
|
|
62
|
-
{ value: "blocked", label: "Blocked" },
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
const COLUMNS: TableColumn[] = [
|
|
66
|
-
{ key: "customer", label: "Customer", flex: 1 },
|
|
67
|
-
{ key: "amount", label: "Amount", width: 150, align: "right" },
|
|
68
|
-
{ key: "status", label: "Verdict", width: 230 },
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
function RunRow({ line, status, onPress }: { line: RunLine; status: RunStatus; onPress: () => void }) {
|
|
72
|
-
const meta = STATUS[status];
|
|
73
|
-
return (
|
|
74
|
-
<TableRow onPress={onPress} accessibilityLabel={`Inspect ${line.customer}`}>
|
|
75
|
-
<TableCell>
|
|
76
|
-
<View style={{ gap: 0 }}>
|
|
77
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{line.customer}</Text>
|
|
78
|
-
<Text size="xs" color="muted" tabular>{line.ref}</Text>
|
|
79
|
-
</View>
|
|
80
|
-
</TableCell>
|
|
81
|
-
<TableCell>
|
|
82
|
-
<Text size="sm" tabular>{formatMoney(line.amount)}</Text>
|
|
83
|
-
</TableCell>
|
|
84
|
-
<TableCell>
|
|
85
|
-
<View style={{ gap: 2 }}>
|
|
86
|
-
<Badge variant="dot" label={meta.label} color={meta.color} />
|
|
87
|
-
{status !== "ready" && line.issue ? (
|
|
88
|
-
<Text size="xs" color={status === "blocked" ? "danger" : "muted"} numberOfLines={1}>{line.issue}</Text>
|
|
89
|
-
) : null}
|
|
90
|
-
</View>
|
|
91
|
-
</TableCell>
|
|
92
|
-
</TableRow>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function InspectLine({ line, status, onResolve }: { line: RunLine; status: RunStatus; onResolve: () => void }) {
|
|
97
|
-
const meta = STATUS[status];
|
|
98
|
-
return (
|
|
99
|
-
<>
|
|
100
|
-
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
|
|
101
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
102
|
-
<Badge variant="dot" label={meta.label} color={meta.color} />
|
|
103
|
-
<Text size="xs" color="muted" tabular style={{ flex: 1, textAlign: "right" }}>{line.ref}</Text>
|
|
104
|
-
</View>
|
|
105
|
-
<Text size="md" weight="medium">{line.customer}</Text>
|
|
106
|
-
<View style={{ gap: 6 }}>
|
|
107
|
-
<DetailRow label="Invoice total"><Text size="sm" weight="semibold" tabular>{formatMoney(line.amount)}</Text></DetailRow>
|
|
108
|
-
<Divider />
|
|
109
|
-
<DetailRow label="Source"><Text size="sm" tabular>{line.ref}</Text></DetailRow>
|
|
110
|
-
</View>
|
|
111
|
-
{status !== "ready" && line.issue ? (
|
|
112
|
-
<View style={{ gap: 4, padding: 14, borderRadius: 10, backgroundColor: status === "blocked" ? colors.red[50] : colors.amber[50] }}>
|
|
113
|
-
<Text size="xs" weight="semibold" color={status === "blocked" ? "danger" : "default"} transform="uppercase">
|
|
114
|
-
{status === "blocked" ? "Blocked" : "Needs review"}
|
|
115
|
-
</Text>
|
|
116
|
-
<Text size="sm">{line.issue}</Text>
|
|
117
|
-
</View>
|
|
118
|
-
) : (
|
|
119
|
-
<Text size="sm" color="muted">No issues — this invoice will post in the run.</Text>
|
|
120
|
-
)}
|
|
121
|
-
</ScrollView>
|
|
122
|
-
<DrawerFooter>
|
|
123
|
-
{status === "blocked" ? (
|
|
124
|
-
<Button title="Resolve & include" color="primary" onPress={onResolve} />
|
|
125
|
-
) : (
|
|
126
|
-
<Button title="Open order record" color="primary" onPress={() => {}} />
|
|
127
|
-
)}
|
|
128
|
-
</DrawerFooter>
|
|
129
|
-
</>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function TplRun() {
|
|
134
|
-
const [resolved, setResolved] = useState<Set<string>>(new Set());
|
|
135
|
-
const [filter, setFilter] = useState("all");
|
|
136
|
-
const [openId, setOpenId] = useState<string | null>(null);
|
|
137
|
-
const [posted, setPosted] = useState(false);
|
|
138
|
-
|
|
139
|
-
// A resolved blocker re-enters the run as ready; a review still posts.
|
|
140
|
-
const statusOf = (l: RunLine): RunStatus => (resolved.has(l.id) && l.status === "blocked" ? "ready" : l.status);
|
|
141
|
-
|
|
142
|
-
const counts = { ready: 0, review: 0, blocked: 0 } as Record<RunStatus, number>;
|
|
143
|
-
LINES.forEach((l) => { counts[statusOf(l)] += 1; });
|
|
144
|
-
|
|
145
|
-
const postable = LINES.filter((l) => statusOf(l) !== "blocked");
|
|
146
|
-
const blocked = LINES.filter((l) => statusOf(l) === "blocked");
|
|
147
|
-
const postValue = postable.reduce((s, l) => s + l.amount, 0);
|
|
148
|
-
|
|
149
|
-
const visible = LINES.filter((l) => filter === "all" || statusOf(l) === filter);
|
|
150
|
-
const open = openId === null ? null : LINES.find((l) => l.id === openId) ?? null;
|
|
151
|
-
|
|
152
|
-
if (posted) {
|
|
153
|
-
return (
|
|
154
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
155
|
-
<View style={{ width: "100%", maxWidth: 1000, alignSelf: "center" }}>
|
|
156
|
-
<Card style={{ padding: 0 }}>
|
|
157
|
-
<CompletionState
|
|
158
|
-
title="Billing run posted"
|
|
159
|
-
summary={`${postable.length} invoices · ${formatMoney(postValue)}${blocked.length ? ` · ${blocked.length} held for fixes` : ""}`}
|
|
160
|
-
>
|
|
161
|
-
<Button title="Start a new run" color="muted" onPress={() => { setPosted(false); setResolved(new Set()); setFilter("all"); }} />
|
|
162
|
-
<Button title="View posted invoices" color="primary" onPress={() => {}} />
|
|
163
|
-
</CompletionState>
|
|
164
|
-
</Card>
|
|
165
|
-
</View>
|
|
166
|
-
</ScrollView>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return (
|
|
171
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
172
|
-
<View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
|
|
173
|
-
{/* header */}
|
|
174
|
-
<View style={{ gap: 2 }}>
|
|
175
|
-
<Text size="xl" weight="semibold">June billing run</Text>
|
|
176
|
-
<Text size="sm" color="muted">Preview every invoice the run will create, clear the blockers, then post — blocked lines are held, never posted</Text>
|
|
177
|
-
</View>
|
|
178
|
-
|
|
179
|
-
{/* the run summary — the verdict breakdown the commit gates on */}
|
|
180
|
-
<KPIStrip
|
|
181
|
-
items={[
|
|
182
|
-
{ label: "Ready", value: counts.ready, format: "number" },
|
|
183
|
-
{ label: "Needs review", value: counts.review, format: "number", tone: counts.review > 0 ? "warning" : "default", info: "Soft issues — these post, but the flag is recorded (missing PO, over credit limit)." },
|
|
184
|
-
{ label: "Blocked", value: counts.blocked, format: "number", tone: counts.blocked > 0 ? "danger" : "default", info: "Hard blockers — held out of the run until resolved (no tax ID, on credit hold)." },
|
|
185
|
-
{ label: "Value to post", value: postValue, format: "currency", compact: true, caption: `${postable.length} invoices` },
|
|
186
|
-
]}
|
|
187
|
-
/>
|
|
188
|
-
|
|
189
|
-
{/* preview register — filter to the verdict you're working */}
|
|
190
|
-
<Card style={{ padding: 0 }}>
|
|
191
|
-
<View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
|
|
192
|
-
<ChipGroup
|
|
193
|
-
accessibilityLabel="Filter by verdict"
|
|
194
|
-
options={FILTERS.map((f) => ({
|
|
195
|
-
label: f.value === "all" ? `All · ${LINES.length}` : `${f.label} · ${counts[f.value as RunStatus]}`,
|
|
196
|
-
value: f.value,
|
|
197
|
-
}))}
|
|
198
|
-
value={filter}
|
|
199
|
-
onValueChange={setFilter}
|
|
200
|
-
/>
|
|
201
|
-
</View>
|
|
202
|
-
<Table columns={COLUMNS}>
|
|
203
|
-
{visible.map((l) => (
|
|
204
|
-
<RunRow key={l.id} line={l} status={statusOf(l)} onPress={() => setOpenId(l.id)} />
|
|
205
|
-
))}
|
|
206
|
-
</Table>
|
|
207
|
-
<CardFooter>
|
|
208
|
-
<Text size="xs" color={blocked.length > 0 ? "danger" : "muted"} style={{ flex: 1 }}>
|
|
209
|
-
{blocked.length > 0
|
|
210
|
-
? `${blocked.length} blocked held back — resolve to include them`
|
|
211
|
-
: "All lines clear — nothing held back"}
|
|
212
|
-
</Text>
|
|
213
|
-
<Button
|
|
214
|
-
title={`Post ${postable.length} invoices`}
|
|
215
|
-
color="primary"
|
|
216
|
-
disabled={postable.length === 0}
|
|
217
|
-
onPress={() => setPosted(true)}
|
|
218
|
-
/>
|
|
219
|
-
</CardFooter>
|
|
220
|
-
</Card>
|
|
221
|
-
</View>
|
|
222
|
-
|
|
223
|
-
{open !== null ? (
|
|
224
|
-
<Drawer open onOpenChange={(o) => !o && setOpenId(null)} title={open.customer} width={440}>
|
|
225
|
-
<InspectLine
|
|
226
|
-
key={open.id}
|
|
227
|
-
line={open}
|
|
228
|
-
status={statusOf(open)}
|
|
229
|
-
onResolve={() => { setResolved((prev) => new Set(prev).add(open.id)); setOpenId(null); }}
|
|
230
|
-
/>
|
|
231
|
-
</Drawer>
|
|
232
|
-
) : null}
|
|
233
|
-
</ScrollView>
|
|
234
|
-
);
|
|
235
|
-
}
|