@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,275 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid, type ColorName } from "@lotics/ui/colors";
|
|
5
|
+
import { Card, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
6
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
7
|
+
import { Badge } from "@lotics/ui/badge";
|
|
8
|
+
import { Button } from "@lotics/ui/button";
|
|
9
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
|
|
10
|
+
import { MenuButton } from "@lotics/ui/menu_button";
|
|
11
|
+
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
12
|
+
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
13
|
+
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
14
|
+
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
15
|
+
import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
|
|
16
|
+
import { TagInput, type TagOption } from "@lotics/ui/tag_input";
|
|
17
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
18
|
+
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
19
|
+
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
20
|
+
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// Template · Inline record — a record edited entirely IN PLACE. Every value
|
|
24
|
+
// reads as plain text; hovering tints it, clicking swaps the field in at the
|
|
25
|
+
// same height (no reflow), and it saves on its own. The data-capture
|
|
26
|
+
// alternative to a form: no screen-wide edit mode, no one big Save — each field
|
|
27
|
+
// is its own atomic edit. Banded cards over zinc-50, label-left / value-right.
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const PLAN = [
|
|
31
|
+
{ value: "starter", label: "Starter" },
|
|
32
|
+
{ value: "growth", label: "Growth" },
|
|
33
|
+
{ value: "enterprise", label: "Enterprise" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const HEALTH = [
|
|
37
|
+
{ value: "healthy", label: "Healthy" },
|
|
38
|
+
{ value: "at_risk", label: "At risk" },
|
|
39
|
+
{ value: "churning", label: "Churning" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const HEALTH_DOT: Record<string, string> = {
|
|
43
|
+
healthy: colors.emerald[500],
|
|
44
|
+
at_risk: colors.amber[500],
|
|
45
|
+
churning: colors.red[500],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Tags — an inline MULTI-select: chips render in the field, search to add, ✕ to
|
|
49
|
+
// remove, free-entry to coin a new tag (`allowCustom`).
|
|
50
|
+
const TAG_OPTIONS: TagOption[] = [
|
|
51
|
+
{ value: "vip", label: "VIP" },
|
|
52
|
+
{ value: "priority", label: "Priority support" },
|
|
53
|
+
{ value: "renewal_risk", label: "Renewal risk" },
|
|
54
|
+
{ value: "expansion", label: "Expansion" },
|
|
55
|
+
{ value: "reference", label: "Referenceable" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Seed the attachment grid with self-contained SVG data-URI "scans" so the
|
|
59
|
+
// thumbnails AND the preview modal work offline; dropped files are real blobs.
|
|
60
|
+
const scan = (id: string, label: string, bg: string): DisplayFile => ({
|
|
61
|
+
id,
|
|
62
|
+
filename: `${label.toLowerCase().replaceAll(" ", "-")}.svg`,
|
|
63
|
+
mimeType: "image/svg+xml",
|
|
64
|
+
url:
|
|
65
|
+
"data:image/svg+xml," +
|
|
66
|
+
encodeURIComponent(
|
|
67
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="420" height="560"><rect width="100%" height="100%" fill="${bg}"/><text x="210" y="290" fill="#ffffff" font-family="sans-serif" font-size="30" text-anchor="middle">${label}</text></svg>`,
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const SEED_DOCS: DisplayFile[] = [
|
|
72
|
+
scan("doc_1", "Master agreement", "#1e3a8a"),
|
|
73
|
+
scan("doc_2", "Tax certificate", "#065f46"),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Simulate an async persist so the saving state is visible.
|
|
77
|
+
function persist<T>(set: (v: T) => void) {
|
|
78
|
+
return (v: T) =>
|
|
79
|
+
new Promise<void>((resolve) => {
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
set(v);
|
|
82
|
+
resolve();
|
|
83
|
+
}, 350);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
88
|
+
return (
|
|
89
|
+
<DetailRow label={label} labelWidth={130} minHeight={40}>
|
|
90
|
+
<View style={{ flex: 1 }}>{children}</View>
|
|
91
|
+
</DetailRow>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function Group({ title, children }: { title: string; children: React.ReactNode }) {
|
|
96
|
+
return (
|
|
97
|
+
<Card style={{ padding: 0 }}>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardHeaderTitle>{title}</CardHeaderTitle>
|
|
100
|
+
</CardHeader>
|
|
101
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 8, gap: 2 }}>{children}</View>
|
|
102
|
+
</Card>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Lifecycle disposition (NOT a plain select). "Lead" is the OPEN default;
|
|
107
|
+
// "Closed"/"Lost" are terminal OUTCOMES the user decides. The control is
|
|
108
|
+
// asymmetric by phase: while open it surfaces the two outcomes as ACTIONS;
|
|
109
|
+
// once resolved it shows the colored STATE with a quiet, reversible Change
|
|
110
|
+
// (reopen to Lead, or switch to the other outcome — valence lives on the badge,
|
|
111
|
+
// not the buttons). States/colors/labels vary per domain, so it's a composition
|
|
112
|
+
// (Badge + Button + Popover/MenuButton), not a shipped primitive.
|
|
113
|
+
type Stage = "lead" | "closed" | "lost";
|
|
114
|
+
|
|
115
|
+
const STAGE_META: Record<Stage, { label: string; color: ColorName }> = {
|
|
116
|
+
lead: { label: "Lead", color: "blue" },
|
|
117
|
+
closed: { label: "Closed", color: "emerald" },
|
|
118
|
+
lost: { label: "Lost", color: "red" },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function StageField({ value, onChange }: { value: Stage; onChange: (next: Stage) => void }) {
|
|
122
|
+
const [open, setOpen] = useState(false);
|
|
123
|
+
const meta = STAGE_META[value];
|
|
124
|
+
|
|
125
|
+
// Pending: the decision is the action.
|
|
126
|
+
if (value === "lead") {
|
|
127
|
+
return (
|
|
128
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
129
|
+
<Badge variant="dot" label={meta.label} color={meta.color} />
|
|
130
|
+
<View style={{ flex: 1 }} />
|
|
131
|
+
<Button title="Mark closed" color="primary" onPress={() => onChange("closed")} />
|
|
132
|
+
<Button title="Mark lost" color="danger-secondary" onPress={() => onChange("lost")} />
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolved: the badge is the state; Change opens a quiet, colored state
|
|
138
|
+
// switcher (each option a dot + label, current marked) — a status control,
|
|
139
|
+
// not a generic text menu.
|
|
140
|
+
return (
|
|
141
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
142
|
+
<Badge variant="dot" label={meta.label} color={meta.color} />
|
|
143
|
+
<View style={{ flex: 1 }} />
|
|
144
|
+
<Popover open={open} onOpenChange={setOpen} side="bottom" align="end">
|
|
145
|
+
<PopoverTrigger>
|
|
146
|
+
<Button title="Change" color="muted" onPress={() => setOpen(true)} />
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
<PopoverContent>
|
|
149
|
+
<View style={{ gap: 2, padding: 4, minWidth: 200 }}>
|
|
150
|
+
{(["lead", "closed", "lost"] as Stage[]).map((s) => (
|
|
151
|
+
<MenuButton
|
|
152
|
+
key={s}
|
|
153
|
+
icon={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: solid(STAGE_META[s].color) }} />}
|
|
154
|
+
title={s === value ? `${STAGE_META[s].label} · current` : STAGE_META[s].label}
|
|
155
|
+
selected={s === value}
|
|
156
|
+
disabled={s === value}
|
|
157
|
+
onPress={() => { onChange(s); setOpen(false); }}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</View>
|
|
161
|
+
</PopoverContent>
|
|
162
|
+
</Popover>
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function TplRecord() {
|
|
168
|
+
const [name, setName] = useState("Northwind Traders");
|
|
169
|
+
const [contact, setContact] = useState("Mara Lindqvist");
|
|
170
|
+
const [email, setEmail] = useState("mara@northwind.co");
|
|
171
|
+
const [phone, setPhone] = useState("");
|
|
172
|
+
const [city, setCity] = useState("Gothenburg");
|
|
173
|
+
const [value, setValue] = useState<number | null>(48000);
|
|
174
|
+
const [plan, setPlan] = useState("growth");
|
|
175
|
+
const [health, setHealth] = useState("healthy");
|
|
176
|
+
const [renews, setRenews] = useState("2026-05-22");
|
|
177
|
+
const [review, setReview] = useState("2026-04-10T14:30");
|
|
178
|
+
const [opens, setOpens] = useState("09:00");
|
|
179
|
+
const [stage, setStage] = useState<Stage>("lead");
|
|
180
|
+
const [tags, setTags] = useState<TagOption[]>([TAG_OPTIONS[0], TAG_OPTIONS[3]]);
|
|
181
|
+
const [docs, setDocs] = useState<DisplayFile[]>(SEED_DOCS);
|
|
182
|
+
const [activeDoc, setActiveDoc] = useState<number | null>(null);
|
|
183
|
+
|
|
184
|
+
const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
188
|
+
<View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 16 }}>
|
|
189
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
190
|
+
<Text size="xl" weight="semibold">
|
|
191
|
+
{name}
|
|
192
|
+
</Text>
|
|
193
|
+
<Badge label="Customer" color="blue" />
|
|
194
|
+
<View style={{ flex: 1 }} />
|
|
195
|
+
<Text size="xs" color="muted">
|
|
196
|
+
Click any value to edit
|
|
197
|
+
</Text>
|
|
198
|
+
</View>
|
|
199
|
+
|
|
200
|
+
<Group title="Contact">
|
|
201
|
+
<Field label="Account name">
|
|
202
|
+
<InlineTextInput value={name} onSave={persist(setName)} accessibilityLabel="Account name" />
|
|
203
|
+
</Field>
|
|
204
|
+
<Field label="Primary contact">
|
|
205
|
+
<InlineTextInput value={contact} onSave={persist(setContact)} accessibilityLabel="Primary contact" />
|
|
206
|
+
</Field>
|
|
207
|
+
<Field label="Email">
|
|
208
|
+
<InlineTextInput value={email} onSave={persist(setEmail)} placeholder="Add email…" accessibilityLabel="Email" />
|
|
209
|
+
</Field>
|
|
210
|
+
<Field label="Phone">
|
|
211
|
+
<InlineTextInput value={phone} onSave={persist(setPhone)} placeholder="Add phone…" accessibilityLabel="Phone" />
|
|
212
|
+
</Field>
|
|
213
|
+
<Field label="City">
|
|
214
|
+
<InlineTextInput value={city} onSave={persist(setCity)} accessibilityLabel="City" />
|
|
215
|
+
</Field>
|
|
216
|
+
</Group>
|
|
217
|
+
|
|
218
|
+
<Group title="Commercial">
|
|
219
|
+
<Field label="Stage">
|
|
220
|
+
<StageField value={stage} onChange={setStage} />
|
|
221
|
+
</Field>
|
|
222
|
+
<Field label="Annual value">
|
|
223
|
+
<InlineNumberInput value={value} onSave={persist(setValue)} format={money} accessibilityLabel="Annual value" />
|
|
224
|
+
</Field>
|
|
225
|
+
<Field label="Plan">
|
|
226
|
+
<InlineSelect value={plan} onSave={persist(setPlan)} options={PLAN} accessibilityLabel="Plan" />
|
|
227
|
+
</Field>
|
|
228
|
+
<Field label="Health">
|
|
229
|
+
<InlineSelect
|
|
230
|
+
value={health}
|
|
231
|
+
onSave={persist(setHealth)}
|
|
232
|
+
options={HEALTH}
|
|
233
|
+
accessibilityLabel="Health"
|
|
234
|
+
renderOptionContent={(o) => (
|
|
235
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
236
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: HEALTH_DOT[o.value] }} />
|
|
237
|
+
<Text size="sm">{o.label}</Text>
|
|
238
|
+
</View>
|
|
239
|
+
)}
|
|
240
|
+
/>
|
|
241
|
+
</Field>
|
|
242
|
+
<Field label="Tags">
|
|
243
|
+
<TagInput value={tags} options={TAG_OPTIONS} onChange={setTags} allowCreate placeholder="Add tags…" accessibilityLabel="Tags" />
|
|
244
|
+
</Field>
|
|
245
|
+
</Group>
|
|
246
|
+
|
|
247
|
+
<Group title="Schedule">
|
|
248
|
+
<Field label="Renews on">
|
|
249
|
+
<InlineDatePicker value={renews} onSave={persist(setRenews)} locale="en-US" accessibilityLabel="Renews on" />
|
|
250
|
+
</Field>
|
|
251
|
+
<Field label="Next review">
|
|
252
|
+
<InlineDatePicker value={review} onSave={persist(setReview)} format="datetime" locale="en-US" accessibilityLabel="Next review" />
|
|
253
|
+
</Field>
|
|
254
|
+
<Field label="Support opens">
|
|
255
|
+
<InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
|
|
256
|
+
</Field>
|
|
257
|
+
</Group>
|
|
258
|
+
|
|
259
|
+
{/* Documents — a richer in-place field: drop to add, click a thumbnail to
|
|
260
|
+
preview in the gallery, ✕ to delete. Not every inline field is a
|
|
261
|
+
same-height swap; an attachment grid edits in place too. */}
|
|
262
|
+
<Card style={{ padding: 0 }}>
|
|
263
|
+
<CardHeader>
|
|
264
|
+
<CardHeaderTitle>Documents</CardHeaderTitle>
|
|
265
|
+
</CardHeader>
|
|
266
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 12, gap: 12 }}>
|
|
267
|
+
{docs.length > 0 ? (
|
|
268
|
+
<FileThumbnailGrid
|
|
269
|
+
files={docs}
|
|
270
|
+
itemSize={84}
|
|
271
|
+
onFilePress={(f) => setActiveDoc(docs.findIndex((x) => x.id === f.id))}
|
|
272
|
+
onRemove={(id) => setDocs((prev) => prev.filter((x) => x.id !== id))}
|
|
273
|
+
/>
|
|
274
|
+
) : null}
|
|
275
|
+
<FileDropzone
|
|
276
|
+
height={96}
|
|
277
|
+
label="Drop contracts or scans"
|
|
278
|
+
hint="or click to browse · PDF, images"
|
|
279
|
+
dropLabel="Release to attach"
|
|
280
|
+
accept="application/pdf,image/*"
|
|
281
|
+
accessibilityLabel="Attach documents"
|
|
282
|
+
onFiles={(dropped) =>
|
|
283
|
+
setDocs((prev) => [
|
|
284
|
+
...prev,
|
|
285
|
+
...dropped.map((f, i) => ({
|
|
286
|
+
id: `${f.name}-${prev.length + i}`,
|
|
287
|
+
filename: f.name,
|
|
288
|
+
mimeType: f.type || "application/octet-stream",
|
|
289
|
+
url: URL.createObjectURL(f),
|
|
290
|
+
})),
|
|
291
|
+
])
|
|
292
|
+
}
|
|
293
|
+
/>
|
|
294
|
+
</View>
|
|
295
|
+
</Card>
|
|
296
|
+
</View>
|
|
297
|
+
|
|
298
|
+
<FileGalleryModal files={docs} activeIndex={activeDoc} onIndexChange={setActiveDoc} captionHint="ESC to close · ←/→ to move" />
|
|
299
|
+
</ScrollView>
|
|
300
|
+
);
|
|
301
|
+
}
|