@lotics/ui 46.3.0 → 46.8.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 +14 -1
- package/docs/catalog.md +118 -6
- package/docs/composition.md +26 -0
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +90 -29
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -259
- package/src/accordion.tsx +7 -1
- package/src/board.tsx +611 -0
- package/src/card.tsx +7 -1
- package/src/charge_lines.tsx +373 -0
- package/src/chip_group.tsx +57 -1
- package/src/file_row.tsx +98 -5
- package/src/icon.tsx +6 -0
- package/src/inline_edit.tsx +54 -10
- package/src/inline_number_input.tsx +5 -1
- package/src/inline_text_input.tsx +1 -1
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/reference_field.tsx +36 -13
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
package/examples/tpl_report.tsx
DELETED
|
@@ -1,410 +0,0 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors, ramp, solid, type ColorName } from "@lotics/ui/colors";
|
|
5
|
-
import { Badge } from "@lotics/ui/badge";
|
|
6
|
-
import { Breakdown } from "@lotics/ui/breakdown";
|
|
7
|
-
import { Button } from "@lotics/ui/button";
|
|
8
|
-
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
9
|
-
import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
|
|
10
|
-
import { type PickerOption } from "@lotics/ui/picker";
|
|
11
|
-
import { DateRangeFilterField } from "@lotics/ui/date_range_filter_field";
|
|
12
|
-
import { type DateFilterValue } from "@lotics/ui/date_filter";
|
|
13
|
-
import { Divider } from "@lotics/ui/divider";
|
|
14
|
-
import { EmptyState } from "@lotics/ui/empty_state";
|
|
15
|
-
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
16
|
-
import { Link } from "@lotics/ui/link";
|
|
17
|
-
import { Pagination } from "@lotics/ui/pagination";
|
|
18
|
-
import { Chip } from "@lotics/ui/chip";
|
|
19
|
-
import { SegmentedControl, type SegmentOption } from "@lotics/ui/segmented_control";
|
|
20
|
-
import { formatMoney } from "@lotics/ui/format_money";
|
|
21
|
-
import { formatDate, parseDate } from "@lotics/ui/format_date";
|
|
22
|
-
|
|
23
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
-
// Template, Lookup report — answer a question by SCOPING a dataset, not by
|
|
25
|
-
// browsing it. The scope bar leads: a period (header band) + ONE search key
|
|
26
|
-
// chosen from several mutually-exclusive dimensions (SegmentedControl → the
|
|
27
|
-
// matching picker; switching the key resets the value). That scope drives
|
|
28
|
-
// everything below — KPI totals → a row of pressable Breakdown facets (the
|
|
29
|
-
// long-tail one folds behind a "Show more") → the paginated register of
|
|
30
|
-
// matching lines → Export. The fee / transaction / usage lookup desk: leave the
|
|
31
|
-
// search empty and a period alone gives the whole-period report.
|
|
32
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
// Search DIMENSIONS — the one-of-N axis you look up by (distinct from the facets
|
|
35
|
-
// you slice by below). Two are entities you pick from a list, one is a free-text
|
|
36
|
-
// code — the SegmentedControl swaps the picker.
|
|
37
|
-
type Dimension = "customer" | "reference" | "operator";
|
|
38
|
-
const DIMENSIONS: SegmentOption<Dimension>[] = [
|
|
39
|
-
{ label: "Customer", value: "customer" },
|
|
40
|
-
{ label: "Reference", value: "reference" },
|
|
41
|
-
{ label: "Operator", value: "operator" },
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
// Facets — attributes you SLICE the scoped set by. Method/status carry meaning
|
|
45
|
-
// (each its own ColorName); category is coherent (one hue, shaded by `ramp`).
|
|
46
|
-
const METHODS = [
|
|
47
|
-
{ key: "transfer", label: "Bank transfer", color: "emerald" },
|
|
48
|
-
{ key: "card", label: "Card", color: "blue" },
|
|
49
|
-
{ key: "cash", label: "Cash", color: "amber" },
|
|
50
|
-
{ key: "wallet", label: "E-wallet", color: "violet" },
|
|
51
|
-
{ key: "cheque", label: "Cheque", color: "zinc" },
|
|
52
|
-
] as const satisfies readonly { key: string; label: string; color: ColorName }[];
|
|
53
|
-
|
|
54
|
-
const STATUSES = [
|
|
55
|
-
{ key: "settled", label: "Settled", color: "emerald" },
|
|
56
|
-
{ key: "pending", label: "Pending", color: "amber" },
|
|
57
|
-
{ key: "refunded", label: "Refunded", color: "zinc" },
|
|
58
|
-
] as const satisfies readonly { key: string; label: string; color: ColorName }[];
|
|
59
|
-
|
|
60
|
-
const CATEGORIES = [
|
|
61
|
-
{ key: "rent", label: "Rent" },
|
|
62
|
-
{ key: "payroll", label: "Payroll" },
|
|
63
|
-
{ key: "supplies", label: "Supplies" },
|
|
64
|
-
{ key: "utilities", label: "Utilities" },
|
|
65
|
-
{ key: "freight", label: "Freight" },
|
|
66
|
-
{ key: "insurance", label: "Insurance" },
|
|
67
|
-
{ key: "marketing", label: "Marketing" },
|
|
68
|
-
{ key: "travel", label: "Travel" },
|
|
69
|
-
{ key: "misc", label: "Misc" },
|
|
70
|
-
] as const;
|
|
71
|
-
const CATEGORY_RAMP = ramp("teal", CATEGORIES.length);
|
|
72
|
-
|
|
73
|
-
const CUSTOMERS = [
|
|
74
|
-
"Atlas Trading Co.", "Brightway Logistics", "Crest Manufacturing", "Delta Foods",
|
|
75
|
-
"Evergreen Supply", "Northstar Retail", "Pioneer Energy", "Sunrise Imports",
|
|
76
|
-
] as const;
|
|
77
|
-
const OPERATORS = [
|
|
78
|
-
"Avery Cole", "Bianca Ross", "Caleb Munoz", "Dana Pham", "Elliot Shaw",
|
|
79
|
-
"Farah Idris", "Grace Tan", "Hugo Berg", "Isla Reyes", "Jonah Webb", "Kira Adams",
|
|
80
|
-
] as const;
|
|
81
|
-
|
|
82
|
-
type MethodKey = (typeof METHODS)[number]["key"];
|
|
83
|
-
type StatusKey = (typeof STATUSES)[number]["key"];
|
|
84
|
-
type CategoryKey = (typeof CATEGORIES)[number]["key"];
|
|
85
|
-
|
|
86
|
-
interface Payment {
|
|
87
|
-
id: string;
|
|
88
|
-
customer: string;
|
|
89
|
-
operator: string;
|
|
90
|
-
method: MethodKey;
|
|
91
|
-
status: StatusKey;
|
|
92
|
-
category: CategoryKey;
|
|
93
|
-
date: string; // ISO yyyy-MM-dd
|
|
94
|
-
amount: number;
|
|
95
|
-
receipt: string | null; // settled payments carry a receipt no.
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 56 deterministic payments — integer hashing (not Math.random) so the page is
|
|
99
|
-
// stable across reloads. Distributions are intentionally uneven, the way a real
|
|
100
|
-
// ledger is: transfers dominate, most settled, a long tail of categories.
|
|
101
|
-
function hash(i: number, salt: number): number {
|
|
102
|
-
let x = (i + 1) * 2654435761 + salt * 40503;
|
|
103
|
-
x = ((x >>> 16) ^ x) * 0x45d9f3b;
|
|
104
|
-
x = ((x >>> 16) ^ x) * 0x45d9f3b;
|
|
105
|
-
return (x >>> 16) % 1000;
|
|
106
|
-
}
|
|
107
|
-
function pick<T>(i: number, salt: number, weighted: [T, number][]): T {
|
|
108
|
-
const total = weighted.reduce((s, [, w]) => s + w, 0);
|
|
109
|
-
let roll = hash(i, salt) % total;
|
|
110
|
-
for (const [item, w] of weighted) {
|
|
111
|
-
if (roll < w) return item;
|
|
112
|
-
roll -= w;
|
|
113
|
-
}
|
|
114
|
-
return weighted[0][0];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const PAYMENTS: Payment[] = Array.from({ length: 56 }, (_, i) => {
|
|
118
|
-
const method = pick<MethodKey>(i, 1, [["transfer", 44], ["card", 24], ["wallet", 14], ["cash", 11], ["cheque", 7]]);
|
|
119
|
-
const status = pick<StatusKey>(i, 2, [["settled", 74], ["pending", 18], ["refunded", 8]]);
|
|
120
|
-
const category = pick<CategoryKey>(i, 3, [["freight", 22], ["supplies", 18], ["payroll", 14], ["rent", 12], ["utilities", 10], ["insurance", 8], ["marketing", 7], ["travel", 5], ["misc", 4]]);
|
|
121
|
-
const customer = CUSTOMERS[hash(i, 4) % CUSTOMERS.length];
|
|
122
|
-
const operator = OPERATORS[hash(i, 5) % OPERATORS.length];
|
|
123
|
-
const day = 1 + (hash(i, 6) % 28);
|
|
124
|
-
const amount = (3 + (hash(i, 7) % 96)) * 500_000; // 1.5M – 49.5M ₫
|
|
125
|
-
return {
|
|
126
|
-
id: `PMT-${String(1042 + i)}`,
|
|
127
|
-
customer, operator, method, status, category,
|
|
128
|
-
date: `2026-05-${String(day).padStart(2, "0")}`,
|
|
129
|
-
amount,
|
|
130
|
-
receipt: status === "settled" ? `RC-${String(8800 + i)}` : null,
|
|
131
|
-
};
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
const PAGE_SIZE = 10;
|
|
135
|
-
const REFERENCES = PAYMENTS.map((p) => p.id);
|
|
136
|
-
const methodOf = (k: MethodKey) => METHODS.find((m) => m.key === k)!;
|
|
137
|
-
const statusOf = (k: StatusKey) => STATUSES.find((s) => s.key === k)!;
|
|
138
|
-
|
|
139
|
-
// A bound's date as a day-start / day-end instant, so the period includes both
|
|
140
|
-
// endpoints whole. (A real app sends these to the server; here it filters rows.)
|
|
141
|
-
function periodBounds(v: DateFilterValue): { start: number | null; end: number | null } {
|
|
142
|
-
const start = v.start.date ? new Date(v.start.date).setHours(0, 0, 0, 0) : null;
|
|
143
|
-
const end = v.end.date ? new Date(v.end.date).setHours(23, 59, 59, 999) : null;
|
|
144
|
-
return { start, end };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function TplReport() {
|
|
148
|
-
// Scope: a period (header) + one search dimension's value (scope bar).
|
|
149
|
-
const [period, setPeriod] = useState<DateFilterValue>({
|
|
150
|
-
start: { date: new Date(2026, 4, 1), time: null },
|
|
151
|
-
end: { date: new Date(2026, 4, 31), time: null },
|
|
152
|
-
});
|
|
153
|
-
const [dimension, setDimension] = useState<Dimension>("customer");
|
|
154
|
-
const [customer, setCustomer] = useState<string | null>(null);
|
|
155
|
-
const [operator, setOperator] = useState<string | null>(null);
|
|
156
|
-
const [reference, setReference] = useState<string | null>(null);
|
|
157
|
-
|
|
158
|
-
// Facets (slice the scoped set) — combine with AND.
|
|
159
|
-
const [method, setMethod] = useState<string | null>(null);
|
|
160
|
-
const [status, setStatus] = useState<string | null>(null);
|
|
161
|
-
const [category, setCategory] = useState<string | null>(null);
|
|
162
|
-
const [page, setPage] = useState(0);
|
|
163
|
-
|
|
164
|
-
// Switching the search dimension clears the value (one key at a time).
|
|
165
|
-
const switchDimension = (d: Dimension) => {
|
|
166
|
-
if (d === dimension) return;
|
|
167
|
-
setDimension(d); setCustomer(null); setOperator(null); setReference(null); setPage(0);
|
|
168
|
-
};
|
|
169
|
-
const setFacet = (set: (v: string | null) => void) => (v: string | null) => { set(v); setPage(0); };
|
|
170
|
-
|
|
171
|
-
const inScope = useMemo(() => {
|
|
172
|
-
const { start, end } = periodBounds(period);
|
|
173
|
-
return (p: Payment) => {
|
|
174
|
-
const t = parseDate(p.date)?.getTime() ?? 0;
|
|
175
|
-
if (start !== null && t < start) return false;
|
|
176
|
-
if (end !== null && t > end) return false;
|
|
177
|
-
if (dimension === "customer" && customer && p.customer !== customer) return false;
|
|
178
|
-
if (dimension === "operator" && operator && p.operator !== operator) return false;
|
|
179
|
-
if (dimension === "reference" && reference && p.id !== reference) return false;
|
|
180
|
-
return true;
|
|
181
|
-
};
|
|
182
|
-
}, [period, dimension, customer, operator, reference]);
|
|
183
|
-
|
|
184
|
-
// Each facet's totals respect the OTHER facets (and the scope) — the slice you
|
|
185
|
-
// could still drill into, not the unfiltered universe.
|
|
186
|
-
const facetMatch = (p: Payment, skip: "method" | "status" | "category") =>
|
|
187
|
-
(skip === "method" || !method || p.method === method) &&
|
|
188
|
-
(skip === "status" || !status || p.status === status) &&
|
|
189
|
-
(skip === "category" || !category || p.category === category);
|
|
190
|
-
|
|
191
|
-
const { byMethod, byStatus, byCategory, rows, total, customers } = useMemo(() => {
|
|
192
|
-
const byMethod = new Map<string, number>();
|
|
193
|
-
const byStatus = new Map<string, number>();
|
|
194
|
-
const byCategory = new Map<string, number>();
|
|
195
|
-
const rows: Payment[] = [];
|
|
196
|
-
const customers = new Set<string>();
|
|
197
|
-
let total = 0;
|
|
198
|
-
for (const p of PAYMENTS) {
|
|
199
|
-
if (!inScope(p)) continue;
|
|
200
|
-
if (facetMatch(p, "method")) byMethod.set(p.method, (byMethod.get(p.method) ?? 0) + p.amount);
|
|
201
|
-
if (facetMatch(p, "status")) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + p.amount);
|
|
202
|
-
if (facetMatch(p, "category")) byCategory.set(p.category, (byCategory.get(p.category) ?? 0) + p.amount);
|
|
203
|
-
if (facetMatch(p, "method") && facetMatch(p, "status") && facetMatch(p, "category")) {
|
|
204
|
-
rows.push(p); total += p.amount; customers.add(p.customer);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return { byMethod, byStatus, byCategory, rows, total, customers };
|
|
208
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
209
|
-
}, [inScope, method, status, category]);
|
|
210
|
-
|
|
211
|
-
const pageRows = rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
212
|
-
|
|
213
|
-
const chips: { key: string; label: string; clear: () => void }[] = [
|
|
214
|
-
...(method ? [{ key: "m", label: methodOf(method as MethodKey).label, clear: () => setFacet(setMethod)(null) }] : []),
|
|
215
|
-
...(status ? [{ key: "s", label: statusOf(status as StatusKey).label, clear: () => setFacet(setStatus)(null) }] : []),
|
|
216
|
-
...(category ? [{ key: "c", label: CATEGORIES.find((c) => c.key === category)!.label, clear: () => setFacet(setCategory)(null) }] : []),
|
|
217
|
-
];
|
|
218
|
-
|
|
219
|
-
return (
|
|
220
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
221
|
-
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
222
|
-
{/* header band — title + the persistent period + the one primary action */}
|
|
223
|
-
<View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap", zIndex: 20 }}>
|
|
224
|
-
<View style={{ gap: 2, flex: 1, minWidth: 260 }}>
|
|
225
|
-
<Text size="xxl" weight="semibold">Payments</Text>
|
|
226
|
-
<Text size="sm" color="muted">Look up payments by customer, reference, or operator across a period — totals, facets, and the matching lines</Text>
|
|
227
|
-
</View>
|
|
228
|
-
<View style={{ flexDirection: "row", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
|
229
|
-
<View style={{ minWidth: 240 }}>
|
|
230
|
-
<DateRangeFilterField value={period} onValueChange={setPeriod} locale="en-US" />
|
|
231
|
-
</View>
|
|
232
|
-
<Button icon="download" title="Export" color="primary" onPress={() => {}} />
|
|
233
|
-
</View>
|
|
234
|
-
</View>
|
|
235
|
-
|
|
236
|
-
{/* scope bar — choose a dimension, then a value (the picker swaps) */}
|
|
237
|
-
<Card>
|
|
238
|
-
<View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 10, zIndex: 10 }}>
|
|
239
|
-
<View style={{ flexDirection: "row", gap: 12, flexWrap: "wrap", alignItems: "center" }}>
|
|
240
|
-
<SegmentedControl
|
|
241
|
-
accessibilityLabel="Search dimension"
|
|
242
|
-
options={DIMENSIONS}
|
|
243
|
-
value={dimension}
|
|
244
|
-
onValueChange={switchDimension}
|
|
245
|
-
/>
|
|
246
|
-
<View style={{ flexGrow: 1, flexBasis: 300, minWidth: 240 }}>
|
|
247
|
-
{dimension === "customer" ? (
|
|
248
|
-
<NamePicker key="customer" names={CUSTOMERS} selected={customer} onSelect={(v) => { setCustomer(v); setPage(0); }} placeholder="Search a customer…" label="customer" />
|
|
249
|
-
) : dimension === "operator" ? (
|
|
250
|
-
<NamePicker key="operator" names={OPERATORS} selected={operator} onSelect={(v) => { setOperator(v); setPage(0); }} placeholder="Search an operator…" label="operator" />
|
|
251
|
-
) : (
|
|
252
|
-
<NamePicker key="reference" names={REFERENCES} selected={reference} onSelect={(v) => { setReference(v); setPage(0); }} placeholder="Search a reference…" label="reference" />
|
|
253
|
-
)}
|
|
254
|
-
</View>
|
|
255
|
-
</View>
|
|
256
|
-
<Text size="xs" color="muted">Leave the search empty to report on every payment in the period.</Text>
|
|
257
|
-
</View>
|
|
258
|
-
</Card>
|
|
259
|
-
|
|
260
|
-
{/* the scoped totals — follow the period + search, not the facets below */}
|
|
261
|
-
<KPIStrip
|
|
262
|
-
items={[
|
|
263
|
-
{ label: "Total", value: total, format: "currency", info: "Sum of every payment matching the period + search — not affected by the facet selections below." },
|
|
264
|
-
{ label: "Payments", value: rows.length, format: "number" },
|
|
265
|
-
{ label: "Customers", value: customers.size, format: "number", info: "Distinct customers in the matching set." },
|
|
266
|
-
]}
|
|
267
|
-
/>
|
|
268
|
-
|
|
269
|
-
{/* facets — press a segment to filter the register; selections combine */}
|
|
270
|
-
<View style={{ flexDirection: "row", gap: 16, alignItems: "stretch", flexWrap: "wrap" }}>
|
|
271
|
-
<Card style={{ padding: 0, flexGrow: 1, flexBasis: 300 }}>
|
|
272
|
-
<CardHeader><CardHeaderTitle info="Payment value by method. Press a method to filter the lines below — combines with status and category.">By method</CardHeaderTitle></CardHeader>
|
|
273
|
-
<View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
|
|
274
|
-
<Breakdown
|
|
275
|
-
items={METHODS.map((m) => ({ key: m.key, label: m.label, value: byMethod.get(m.key) ?? 0, color: solid(m.color) })).filter((e) => e.value > 0)}
|
|
276
|
-
selectedKey={method}
|
|
277
|
-
onSelect={setFacet(setMethod)}
|
|
278
|
-
formatValue={(n) => formatMoney(n, { compact: true })}
|
|
279
|
-
/>
|
|
280
|
-
</View>
|
|
281
|
-
</Card>
|
|
282
|
-
<Card style={{ padding: 0, flexGrow: 1, flexBasis: 300 }}>
|
|
283
|
-
<CardHeader><CardHeaderTitle info="Where each payment stands — pending and refunded are the slices to watch.">By status</CardHeaderTitle></CardHeader>
|
|
284
|
-
<View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
|
|
285
|
-
<Breakdown
|
|
286
|
-
items={STATUSES.map((s) => ({ key: s.key, label: s.label, value: byStatus.get(s.key) ?? 0, color: solid(s.color) })).filter((e) => e.value > 0)}
|
|
287
|
-
selectedKey={status}
|
|
288
|
-
onSelect={setFacet(setStatus)}
|
|
289
|
-
formatValue={(n) => formatMoney(n, { compact: true })}
|
|
290
|
-
/>
|
|
291
|
-
</View>
|
|
292
|
-
</Card>
|
|
293
|
-
<Card style={{ padding: 0, flexGrow: 1, flexBasis: 300 }}>
|
|
294
|
-
<CardHeader><CardHeaderTitle info="Value by spend category. The long tail folds behind a toggle so the card stays the same height as its neighbours.">By category</CardHeaderTitle></CardHeader>
|
|
295
|
-
<View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
|
|
296
|
-
<Breakdown
|
|
297
|
-
items={CATEGORIES.map((c, i) => ({ key: c.key, label: c.label, value: byCategory.get(c.key) ?? 0, color: CATEGORY_RAMP[i] })).filter((e) => e.value > 0).sort((a, b) => b.value - a.value)}
|
|
298
|
-
selectedKey={category}
|
|
299
|
-
onSelect={setFacet(setCategory)}
|
|
300
|
-
formatValue={(n) => formatMoney(n, { compact: true })}
|
|
301
|
-
maxRows={5}
|
|
302
|
-
/>
|
|
303
|
-
</View>
|
|
304
|
-
</Card>
|
|
305
|
-
</View>
|
|
306
|
-
|
|
307
|
-
{/* the register — paginated; scope + facets feed it */}
|
|
308
|
-
<Card style={{ padding: 0 }}>
|
|
309
|
-
<CardHeader>
|
|
310
|
-
<CardHeaderTitle info="Every payment matching the scope + facets. The receipt links the settled ones.">Payments</CardHeaderTitle>
|
|
311
|
-
{chips.length > 0 ? (
|
|
312
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
313
|
-
{chips.map((c) => (
|
|
314
|
-
<Chip key={c.key} onDismiss={c.clear} dismissTooltip="Clear filter">
|
|
315
|
-
<Text size="xs" weight="medium" color="muted">{c.label}</Text>
|
|
316
|
-
</Chip>
|
|
317
|
-
))}
|
|
318
|
-
</View>
|
|
319
|
-
) : null}
|
|
320
|
-
</CardHeader>
|
|
321
|
-
|
|
322
|
-
{/* eyebrow columns */}
|
|
323
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
|
|
324
|
-
<Text size="xs" color="muted" weight="medium" style={{ width: 92 }}>Reference</Text>
|
|
325
|
-
<Text size="xs" color="muted" weight="medium" style={{ flex: 1.3 }}>Customer</Text>
|
|
326
|
-
<Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Operator</Text>
|
|
327
|
-
<Text size="xs" color="muted" weight="medium" style={{ width: 70 }}>Date</Text>
|
|
328
|
-
<Text size="xs" color="muted" weight="medium" tabular align="right" style={{ width: 116 }}>Amount</Text>
|
|
329
|
-
<Text size="xs" color="muted" weight="medium" style={{ width: 92 }}>Receipt</Text>
|
|
330
|
-
<Text size="xs" color="muted" weight="medium" style={{ width: 96 }}>Status</Text>
|
|
331
|
-
</View>
|
|
332
|
-
<Divider />
|
|
333
|
-
|
|
334
|
-
{pageRows.length === 0 ? (
|
|
335
|
-
<EmptyState message="No payments match this scope" hint="Widen the period, change the search, or clear a facet chip" />
|
|
336
|
-
) : (
|
|
337
|
-
pageRows.map((p, i) => (
|
|
338
|
-
<View key={p.id}>
|
|
339
|
-
{i > 0 ? <Divider /> : null}
|
|
340
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, minHeight: 56 }}>
|
|
341
|
-
<Text size="sm" weight="medium" tabular style={{ width: 92 }}>{p.id}</Text>
|
|
342
|
-
<View style={{ flex: 1.3, gap: 2 }}>
|
|
343
|
-
<Text size="sm" numberOfLines={1}>{p.customer}</Text>
|
|
344
|
-
<Text size="xs" color="muted">{methodOf(p.method).label}</Text>
|
|
345
|
-
</View>
|
|
346
|
-
<Text size="sm" color="muted" numberOfLines={1} style={{ flex: 1 }}>{p.operator}</Text>
|
|
347
|
-
<Text size="sm" tabular style={{ width: 70 }}>{formatDate(p.date, { format: "dayMonth", locale: "en-US" })}</Text>
|
|
348
|
-
<Text size="sm" weight="medium" tabular align="right" style={{ width: 116 }}>{formatMoney(p.amount)}</Text>
|
|
349
|
-
<View style={{ width: 92 }}>
|
|
350
|
-
{p.receipt ? (
|
|
351
|
-
<Link onPress={() => {}} accessibilityLabel={`Open receipt ${p.receipt}`}>{p.receipt}</Link>
|
|
352
|
-
) : (
|
|
353
|
-
<Text size="sm" color="muted">—</Text>
|
|
354
|
-
)}
|
|
355
|
-
</View>
|
|
356
|
-
<View style={{ width: 96 }}>
|
|
357
|
-
<Badge variant="dot" label={statusOf(p.status).label} color={statusOf(p.status).color} />
|
|
358
|
-
</View>
|
|
359
|
-
</View>
|
|
360
|
-
</View>
|
|
361
|
-
))
|
|
362
|
-
)}
|
|
363
|
-
|
|
364
|
-
<CardFooter>
|
|
365
|
-
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>
|
|
366
|
-
{`${rows.length.toLocaleString("en-US")} payments totalling ${formatMoney(total, { compact: true })}${chips.length > 0 ? " (filtered)" : ""}`}
|
|
367
|
-
</Text>
|
|
368
|
-
<Pagination
|
|
369
|
-
page={page}
|
|
370
|
-
pageSize={PAGE_SIZE}
|
|
371
|
-
rowCount={pageRows.length}
|
|
372
|
-
hasMore={(page + 1) * PAGE_SIZE < rows.length}
|
|
373
|
-
total={rows.length}
|
|
374
|
-
onPageChange={setPage}
|
|
375
|
-
/>
|
|
376
|
-
</CardFooter>
|
|
377
|
-
</Card>
|
|
378
|
-
</View>
|
|
379
|
-
</ScrollView>
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// A pick-from-list search — a Combobox over a fixed name list, filtered as you
|
|
384
|
-
// type, with an inline clear. The scope bar's entity dimensions (customer,
|
|
385
|
-
// operator) use it; the code dimension (reference) uses a free-text SearchInput.
|
|
386
|
-
function NamePicker(props: {
|
|
387
|
-
names: readonly string[];
|
|
388
|
-
selected: string | null;
|
|
389
|
-
onSelect: (v: string | null) => void;
|
|
390
|
-
placeholder: string;
|
|
391
|
-
label: string;
|
|
392
|
-
}) {
|
|
393
|
-
const { names, selected, onSelect, placeholder, label } = props;
|
|
394
|
-
const [q, setQ] = useState("");
|
|
395
|
-
const options = useMemo<PickerOption<string>[]>(() => {
|
|
396
|
-
const needle = q.trim().toLowerCase();
|
|
397
|
-
return names.filter((n) => !needle || n.toLowerCase().includes(needle)).map((n) => ({ value: n, label: n }));
|
|
398
|
-
}, [names, q]);
|
|
399
|
-
return (
|
|
400
|
-
<Combobox<string>
|
|
401
|
-
options={options}
|
|
402
|
-
value={selected ? { value: selected, label: selected } : null}
|
|
403
|
-
onSearchChange={setQ}
|
|
404
|
-
onValueChange={(opt) => onSelect(opt.value)}
|
|
405
|
-
>
|
|
406
|
-
<ComboboxInput clearable clearLabel={`Clear ${label}`} onClear={() => onSelect(null)} placeholder={placeholder} accessibilityLabel={`Search ${label}`} />
|
|
407
|
-
<ComboboxContent emptyText={`No ${label} matches`} />
|
|
408
|
-
</Combobox>
|
|
409
|
-
);
|
|
410
|
-
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors } from "@lotics/ui/colors";
|
|
5
|
-
import { Button } from "@lotics/ui/button";
|
|
6
|
-
import { Divider } from "@lotics/ui/divider";
|
|
7
|
-
|
|
8
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
-
// Template, Financial statements — the income statement, the balance sheet,
|
|
10
|
-
// and the cash flow statement, all speaking ONE statement grammar: column
|
|
11
|
-
// captions right-aligned over fixed money columns, items indented under their
|
|
12
|
-
// group, a hairline rule above every subtotal, the grand total DOUBLE-RULED
|
|
13
|
-
// (the accounting convention), negatives in accounting parentheses, no bars,
|
|
14
|
-
// no charts — the numbers are the interface. The three statements TIE: net
|
|
15
|
-
// income flows into retained earnings, the cash flow's closing cash IS the
|
|
16
|
-
// balance sheet's cash, the loan repayment moves the debt line. Figures are
|
|
17
|
-
// per-cell currency-free (the meta line says "VND" once).
|
|
18
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
/** Accounting formatting: thousands-dotted, negatives in parentheses. */
|
|
21
|
-
const num = (n: number) => {
|
|
22
|
-
const s = Math.abs(n).toLocaleString("vi-VN");
|
|
23
|
-
return n < 0 ? `(${s})` : s;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type LineKind = "header" | "item" | "subtotal" | "total";
|
|
27
|
-
|
|
28
|
-
interface Line {
|
|
29
|
-
kind: LineKind;
|
|
30
|
-
label: string;
|
|
31
|
-
/** One value per column; null renders an empty cell (headers usually). */
|
|
32
|
-
values?: (number | null)[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const COL_WIDTH = 124;
|
|
36
|
-
|
|
37
|
-
/** One statement: right-aligned column captions, then the lines. The grammar —
|
|
38
|
-
* header (group label, no values), item (indented, plain), subtotal (rule
|
|
39
|
-
* above, semibold), total (DOUBLE rule above, semibold) — covers all three
|
|
40
|
-
* statements; only the data differs. */
|
|
41
|
-
function Statement({ columns, lines }: { columns: string[]; lines: Line[] }) {
|
|
42
|
-
const cells = (l: Line, weight?: "medium" | "semibold") =>
|
|
43
|
-
(l.values ?? columns.map(() => null)).map((v, i) => (
|
|
44
|
-
<Text key={i} size="sm" weight={weight} tabular align="right" style={{ width: COL_WIDTH }}>
|
|
45
|
-
{v == null ? "" : num(v)}
|
|
46
|
-
</Text>
|
|
47
|
-
));
|
|
48
|
-
return (
|
|
49
|
-
<View>
|
|
50
|
-
{/* column captions */}
|
|
51
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingBottom: 6 }}>
|
|
52
|
-
<View style={{ flex: 1 }} />
|
|
53
|
-
{columns.map((c) => (
|
|
54
|
-
<Text key={c} size="xs" weight="medium" color="muted" align="right" style={{ width: COL_WIDTH }}>
|
|
55
|
-
{c}
|
|
56
|
-
</Text>
|
|
57
|
-
))}
|
|
58
|
-
</View>
|
|
59
|
-
<Divider />
|
|
60
|
-
<View style={{ paddingTop: 6, gap: 2 }}>
|
|
61
|
-
{lines.map((l, i) => {
|
|
62
|
-
if (l.kind === "header") {
|
|
63
|
-
return (
|
|
64
|
-
<Text key={i} size="sm" weight="semibold" style={{ paddingTop: i === 0 ? 0 : 10 }}>
|
|
65
|
-
{l.label}
|
|
66
|
-
</Text>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
if (l.kind === "item") {
|
|
70
|
-
return (
|
|
71
|
-
<View key={i} style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 26 }}>
|
|
72
|
-
<Text size="sm" style={{ flex: 1, paddingLeft: 12 }}>{l.label}</Text>
|
|
73
|
-
{cells(l)}
|
|
74
|
-
</View>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
if (l.kind === "subtotal") {
|
|
78
|
-
return (
|
|
79
|
-
<View key={i} style={{ gap: 2 }}>
|
|
80
|
-
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
81
|
-
<View style={{ flex: 1 }} />
|
|
82
|
-
<View style={{ width: COL_WIDTH * columns.length + 12 * (columns.length - 1), borderTopWidth: 1, borderTopColor: colors.zinc[200] }} />
|
|
83
|
-
</View>
|
|
84
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 26 }}>
|
|
85
|
-
<Text size="sm" weight="medium" style={{ flex: 1 }}>{l.label}</Text>
|
|
86
|
-
{cells(l, "medium")}
|
|
87
|
-
</View>
|
|
88
|
-
</View>
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
// total — the double rule
|
|
92
|
-
return (
|
|
93
|
-
<View key={i} style={{ gap: 2, paddingTop: 4 }}>
|
|
94
|
-
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
95
|
-
<View style={{ flex: 1 }} />
|
|
96
|
-
<View style={{ width: COL_WIDTH * columns.length + 12 * (columns.length - 1), gap: 2 }}>
|
|
97
|
-
<View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[400] }} />
|
|
98
|
-
<View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[400] }} />
|
|
99
|
-
</View>
|
|
100
|
-
</View>
|
|
101
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 30 }}>
|
|
102
|
-
<Text size="sm" weight="semibold" style={{ flex: 1 }}>{l.label}</Text>
|
|
103
|
-
{cells(l, "semibold")}
|
|
104
|
-
</View>
|
|
105
|
-
</View>
|
|
106
|
-
);
|
|
107
|
-
})}
|
|
108
|
-
</View>
|
|
109
|
-
</View>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** The statement page: title + the one meta line (entity, period, basis,
|
|
114
|
-
* currency, stated ONCE so cells stay currency-free) + Export, then the
|
|
115
|
-
* statement column. */
|
|
116
|
-
function StatementPage({ title, meta, children }: { title: string; meta: string; children: ReactNode }) {
|
|
117
|
-
return (
|
|
118
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
|
|
119
|
-
<View style={{ width: "100%", maxWidth: 680, alignSelf: "center", gap: 20 }}>
|
|
120
|
-
<View style={{ flexDirection: "row", alignItems: "flex-start", flexWrap: "wrap", columnGap: 12, rowGap: 8 }}>
|
|
121
|
-
<View style={{ flexGrow: 1, flexBasis: 240, gap: 2 }}>
|
|
122
|
-
<Text size="xxl" weight="semibold">{title}</Text>
|
|
123
|
-
<Text size="sm" color="muted">{meta}</Text>
|
|
124
|
-
</View>
|
|
125
|
-
<Button title="Export" color="secondary" icon="file-down" onPress={() => {}} />
|
|
126
|
-
</View>
|
|
127
|
-
{children}
|
|
128
|
-
<Text size="xs" color="muted">Unaudited management accounts. Figures in VND.</Text>
|
|
129
|
-
</View>
|
|
130
|
-
</ScrollView>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ── the data — internally exact AND tied across the three statements:
|
|
135
|
-
// net income 81.840 → retained earnings 203.600 + 81.840 − 40.740 dividends
|
|
136
|
-
// = 244.700; closing cash 400.800 IS the balance sheet's cash; the loan
|
|
137
|
-
// repayment takes long-term debt 90.000 → 80.000. (thousands of VND)
|
|
138
|
-
const K = 1000;
|
|
139
|
-
|
|
140
|
-
export function TplIncomeStatement() {
|
|
141
|
-
const lines: Line[] = [
|
|
142
|
-
{ kind: "header", label: "Revenue" },
|
|
143
|
-
{ kind: "item", label: "Service revenue", values: [842_500 * K, 781_200 * K] },
|
|
144
|
-
{ kind: "item", label: "Disbursement recharges", values: [128_400 * K, 118_900 * K] },
|
|
145
|
-
{ kind: "subtotal", label: "Total revenue", values: [970_900 * K, 900_100 * K] },
|
|
146
|
-
{ kind: "header", label: "Cost of services" },
|
|
147
|
-
{ kind: "item", label: "Carrier & handling", values: [-512_300 * K, -471_800 * K] },
|
|
148
|
-
{ kind: "item", label: "Customs & levies", values: [-148_200 * K, -139_600 * K] },
|
|
149
|
-
{ kind: "subtotal", label: "Gross profit", values: [310_400 * K, 288_700 * K] },
|
|
150
|
-
{ kind: "header", label: "Operating expenses" },
|
|
151
|
-
{ kind: "item", label: "Salaries & benefits", values: [-142_000 * K, -138_500 * K] },
|
|
152
|
-
{ kind: "item", label: "Office & software", values: [-38_400 * K, -37_200 * K] },
|
|
153
|
-
{ kind: "item", label: "Marketing", values: [-21_500 * K, -18_900 * K] },
|
|
154
|
-
{ kind: "subtotal", label: "Operating income", values: [108_500 * K, 94_100 * K] },
|
|
155
|
-
{ kind: "item", label: "Interest expense", values: [-6_200 * K, -6_400 * K] },
|
|
156
|
-
{ kind: "subtotal", label: "Profit before tax", values: [102_300 * K, 87_700 * K] },
|
|
157
|
-
{ kind: "item", label: "Income tax (20%)", values: [-20_460 * K, -17_540 * K] },
|
|
158
|
-
{ kind: "total", label: "Net income", values: [81_840 * K, 70_160 * K] },
|
|
159
|
-
];
|
|
160
|
-
return (
|
|
161
|
-
<StatementPage title="Income statement" meta="VITTORIA Logistics, month ended 30/06/2026, VND">
|
|
162
|
-
<Statement columns={["Jun 2026", "May 2026"]} lines={lines} />
|
|
163
|
-
</StatementPage>
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function TplBalanceSheet() {
|
|
168
|
-
const lines: Line[] = [
|
|
169
|
-
{ kind: "header", label: "Assets" },
|
|
170
|
-
{ kind: "item", label: "Cash & equivalents", values: [400_800 * K, 355_100 * K] },
|
|
171
|
-
{ kind: "item", label: "Accounts receivable", values: [386_200 * K, 401_600 * K] },
|
|
172
|
-
{ kind: "item", label: "Advances & deposits", values: [54_500 * K, 48_200 * K] },
|
|
173
|
-
{ kind: "subtotal", label: "Current assets", values: [841_500 * K, 804_900 * K] },
|
|
174
|
-
{ kind: "item", label: "Equipment, net", values: [130_400 * K, 121_900 * K] },
|
|
175
|
-
{ kind: "total", label: "Total assets", values: [971_900 * K, 926_800 * K] },
|
|
176
|
-
{ kind: "header", label: "Liabilities" },
|
|
177
|
-
{ kind: "item", label: "Accounts payable", values: [268_300 * K, 259_400 * K] },
|
|
178
|
-
{ kind: "item", label: "Taxes payable", values: [42_100 * K, 38_600 * K] },
|
|
179
|
-
{ kind: "item", label: "Accrued salaries", values: [36_800 * K, 35_200 * K] },
|
|
180
|
-
{ kind: "subtotal", label: "Current liabilities", values: [347_200 * K, 333_200 * K] },
|
|
181
|
-
{ kind: "item", label: "Long-term debt", values: [80_000 * K, 90_000 * K] },
|
|
182
|
-
{ kind: "subtotal", label: "Total liabilities", values: [427_200 * K, 423_200 * K] },
|
|
183
|
-
{ kind: "header", label: "Equity" },
|
|
184
|
-
{ kind: "item", label: "Contributed capital", values: [300_000 * K, 300_000 * K] },
|
|
185
|
-
{ kind: "item", label: "Retained earnings", values: [244_700 * K, 203_600 * K] },
|
|
186
|
-
{ kind: "subtotal", label: "Total equity", values: [544_700 * K, 503_600 * K] },
|
|
187
|
-
{ kind: "total", label: "Total liabilities & equity", values: [971_900 * K, 926_800 * K] },
|
|
188
|
-
];
|
|
189
|
-
return (
|
|
190
|
-
<StatementPage title="Balance sheet" meta="VITTORIA Logistics, as at 30/06/2026, VND">
|
|
191
|
-
<Statement columns={["30/06/2026", "31/05/2026"]} lines={lines} />
|
|
192
|
-
</StatementPage>
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function TplCashflow() {
|
|
197
|
-
const lines: Line[] = [
|
|
198
|
-
{ kind: "header", label: "Operating activities" },
|
|
199
|
-
{ kind: "item", label: "Net income", values: [81_840 * K] },
|
|
200
|
-
{ kind: "item", label: "Depreciation", values: [3_500 * K] },
|
|
201
|
-
{ kind: "item", label: "Decrease in accounts receivable", values: [15_400 * K] },
|
|
202
|
-
{ kind: "item", label: "Increase in accounts payable", values: [8_900 * K] },
|
|
203
|
-
{ kind: "item", label: "Other working-capital movements", values: [-1_200 * K] },
|
|
204
|
-
{ kind: "subtotal", label: "Net cash from operating activities", values: [108_440 * K] },
|
|
205
|
-
{ kind: "header", label: "Investing activities" },
|
|
206
|
-
{ kind: "item", label: "Purchases of equipment", values: [-12_000 * K] },
|
|
207
|
-
{ kind: "subtotal", label: "Net cash used in investing activities", values: [-12_000 * K] },
|
|
208
|
-
{ kind: "header", label: "Financing activities" },
|
|
209
|
-
{ kind: "item", label: "Repayment of long-term debt", values: [-10_000 * K] },
|
|
210
|
-
{ kind: "item", label: "Dividends paid", values: [-40_740 * K] },
|
|
211
|
-
{ kind: "subtotal", label: "Net cash used in financing activities", values: [-50_740 * K] },
|
|
212
|
-
{ kind: "subtotal", label: "Net increase in cash", values: [45_700 * K] },
|
|
213
|
-
{ kind: "item", label: "Cash at 01/06/2026", values: [355_100 * K] },
|
|
214
|
-
{ kind: "total", label: "Cash at 30/06/2026", values: [400_800 * K] },
|
|
215
|
-
];
|
|
216
|
-
return (
|
|
217
|
-
<StatementPage title="Cash flow statement" meta="VITTORIA Logistics, month ended 30/06/2026, VND">
|
|
218
|
-
<Statement columns={["Jun 2026"]} lines={lines} />
|
|
219
|
-
</StatementPage>
|
|
220
|
-
);
|
|
221
|
-
}
|