@lotics/ui 4.7.0 → 4.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 +10 -4
- package/examples/tpl_report.tsx +413 -0
- package/examples/tpl_stock.tsx +9 -1
- package/package.json +1 -1
- package/src/breakdown.tsx +48 -4
- package/src/combobox.tsx +14 -3
- package/src/search_input.tsx +14 -12
package/AGENTS.md
CHANGED
|
@@ -56,7 +56,9 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
56
56
|
with `Pagination`). Never an HTML `<table>` or a `.map` of rows.
|
|
57
57
|
- **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
|
|
58
58
|
(delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG set — no
|
|
59
|
-
recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown
|
|
59
|
+
recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`
|
|
60
|
+
(a stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
|
|
61
|
+
"Show N more" toggle — `labels` to localize — so several facet cards align to one height in a row),
|
|
60
62
|
`StatusGrid` + `StatusLegend`, `Heatmap`.
|
|
61
63
|
- **Surfaces & layout** — `Card` (+ `CardHeader`/`CardHeaderTitle`/`CardHeaderMeta`/`CardBody`/
|
|
62
64
|
`CardFooter`), `SectionCard`, `SectionHeading` (+ `SectionHeadingTitle`), `PageHeader` /
|
|
@@ -414,8 +416,10 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
414
416
|
highlight (and `marked` — a resting blue tint — for a bulk-ticked row). `Peek` is ONLY for
|
|
415
417
|
secondary references, never the primary row press.
|
|
416
418
|
- **One view-control vocabulary** (all 40px, `sm` labels, in one wrapping band):
|
|
417
|
-
· `SearchInput` is THE search box — a
|
|
418
|
-
|
|
419
|
+
· `SearchInput` is THE search box — a white `TextInputField` preset (rounded-8 like every other
|
|
420
|
+
input, a thin zinc-300 border, the leading search glyph + a clear ✕). It reads as search by its
|
|
421
|
+
ICON + white fill, not a distinct shape, so a toolbar of controls stays one consistent band.
|
|
422
|
+
Never a bare `TextInputField` + search icon.
|
|
419
423
|
· `ChipGroup` is THE one-of-N lens (≤ ~10 options the user flips between; include "All"; counts in
|
|
420
424
|
the label). Filters ONE list to a SUBSET — including by process stage / lifecycle state.
|
|
421
425
|
· `FilterPill` is THE secondary-dimension filter — a compact pill that opens a composed popover
|
|
@@ -462,7 +466,9 @@ Full worked-example screens ship in the package — **read the source at
|
|
|
462
466
|
recipe for a screen JOB; copy and adapt). Pick by the job:
|
|
463
467
|
|
|
464
468
|
- **Monitor & decide** — `tpl_dashboard` (KPIStrip + trends + attention list) · `tpl_stock`
|
|
465
|
-
(10k-record faceted funnel) · `
|
|
469
|
+
(10k-record faceted funnel) · `tpl_report` (scope-first lookup report — a header period +
|
|
470
|
+
one-of-N dimension search drives KPIs → aligned facets w/ a `maxRows` long-tail toggle →
|
|
471
|
+
paginated register → export) · `tpl_tower` (live `StatusGrid` wallboard) · `tpl_rollup`
|
|
466
472
|
(hierarchical totals).
|
|
467
473
|
- **Work execution** — `app_orders` (stage-gated work queue) · `tpl_crm_desk` (assign & work +
|
|
468
474
|
`FloatingActionBar`) · `tpl_callsheet` (call console) · `tpl_convert` (staged conversion) ·
|
|
@@ -0,0 +1,413 @@
|
|
|
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 } 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 { PillButton } from "@lotics/ui/pill_button";
|
|
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.zinc[50] }} 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="xl" 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
|
+
<PillButton key={c.key} onDismiss={c.clear} dismissTooltip="Clear filter">
|
|
315
|
+
<Text size="xs" weight="medium" color="muted">{c.label}</Text>
|
|
316
|
+
</PillButton>
|
|
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" transform="uppercase" style={{ width: 92 }}>Reference</Text>
|
|
325
|
+
<Text size="xs" color="muted" transform="uppercase" style={{ flex: 1.3 }}>Customer</Text>
|
|
326
|
+
<Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>Operator</Text>
|
|
327
|
+
<Text size="xs" color="muted" transform="uppercase" style={{ width: 70 }}>Date</Text>
|
|
328
|
+
<Text size="xs" color="muted" transform="uppercase" tabular align="right" style={{ width: 116 }}>Amount</Text>
|
|
329
|
+
<Text size="xs" color="muted" transform="uppercase" style={{ width: 92 }}>Receipt</Text>
|
|
330
|
+
<Text size="xs" color="muted" transform="uppercase" 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 · ${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
|
+
clearable
|
|
404
|
+
clearLabel={`Clear ${label}`}
|
|
405
|
+
onClear={() => onSelect(null)}
|
|
406
|
+
onSearchChange={setQ}
|
|
407
|
+
onValueChange={(opt) => onSelect(opt.value)}
|
|
408
|
+
placeholder={placeholder}
|
|
409
|
+
emptyText={`No ${label} matches`}
|
|
410
|
+
accessibilityLabel={`Search ${label}`}
|
|
411
|
+
/>
|
|
412
|
+
);
|
|
413
|
+
}
|
package/examples/tpl_stock.tsx
CHANGED
|
@@ -56,6 +56,11 @@ const DEPOTS = [
|
|
|
56
56
|
{ key: "northgate", label: "Northgate" },
|
|
57
57
|
{ key: "riverside", label: "Riverside" },
|
|
58
58
|
{ key: "southbay", label: "Southbay" },
|
|
59
|
+
{ key: "westpoint", label: "Westpoint" },
|
|
60
|
+
{ key: "harborview", label: "Harborview" },
|
|
61
|
+
{ key: "lakeside", label: "Lakeside" },
|
|
62
|
+
{ key: "midvale", label: "Midvale" },
|
|
63
|
+
{ key: "fairlawn", label: "Fairlawn" },
|
|
59
64
|
] as const;
|
|
60
65
|
const DEPOT_RAMP = ramp("violet", DEPOTS.length);
|
|
61
66
|
|
|
@@ -101,7 +106,7 @@ const TYPE_BASE_VALUE: Record<TypeKey, number> = {
|
|
|
101
106
|
const UNITS: Unit[] = Array.from({ length: 10_482 }, (_, i) => {
|
|
102
107
|
const type = pick<TypeKey>(i, 1, [["dry40", 34], ["dry20", 26], ["hc40", 18], ["reefer", 12], ["opentop", 7], ["tank", 3]]);
|
|
103
108
|
const status = pick<StatusKey>(i, 2, [["leased", 52], ["yard", 18], ["transit", 14], ["repair", 9], ["idle", 7]]);
|
|
104
|
-
const depot = pick<DepotKey>(i, 3, [["eastport",
|
|
109
|
+
const depot = pick<DepotKey>(i, 3, [["eastport", 26], ["northgate", 19], ["riverside", 15], ["southbay", 11], ["westpoint", 9], ["harborview", 7], ["lakeside", 5], ["midvale", 4], ["fairlawn", 4]]);
|
|
105
110
|
const ageYears = 1 + (hash(i, 4) % 12);
|
|
106
111
|
const day = 1 + (hash(i, 5) % 28);
|
|
107
112
|
const month = status === "idle" ? 1 + (hash(i, 6) % 4) : 4 + (hash(i, 6) % 3);
|
|
@@ -241,10 +246,13 @@ export function TplStock() {
|
|
|
241
246
|
</CardHeaderTitle>
|
|
242
247
|
</CardHeader>
|
|
243
248
|
<View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
|
|
249
|
+
{/* maxRows caps the long tail behind a "Show N more" toggle, so a
|
|
250
|
+
many-depot facet stays the same height as its neighbours. */}
|
|
244
251
|
<Breakdown
|
|
245
252
|
items={DEPOTS.map((d, i) => ({ key: d.key, label: d.label, value: byDepot.get(d.key) ?? 0, color: DEPOT_RAMP[i] }))}
|
|
246
253
|
selectedKey={depot}
|
|
247
254
|
onSelect={setFacet(setDepot)}
|
|
255
|
+
maxRows={5}
|
|
248
256
|
/>
|
|
249
257
|
</View>
|
|
250
258
|
</Card>
|
package/package.json
CHANGED
package/src/breakdown.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { StyleSheet, View } from "react-native";
|
|
2
3
|
import { colors, withAlpha } from "./colors";
|
|
4
|
+
import { Icon } from "./icon";
|
|
3
5
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
6
|
import { StackedProgressBar } from "./stacked_progress_bar";
|
|
5
7
|
import { Text } from "./text";
|
|
@@ -12,6 +14,15 @@ export interface BreakdownItem {
|
|
|
12
14
|
color: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/** Expand/collapse labels for a capped breakdown (`maxRows`). Localized
|
|
18
|
+
* downstream — the kit ships English defaults. */
|
|
19
|
+
export interface BreakdownLabels {
|
|
20
|
+
/** The expand affordance, given how many rows are currently hidden. */
|
|
21
|
+
more: (hidden: number) => string;
|
|
22
|
+
/** The collapse affordance. */
|
|
23
|
+
less: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export interface BreakdownProps {
|
|
16
27
|
items: BreakdownItem[];
|
|
17
28
|
/** The drilled-in segment. While set, the other segments dim. */
|
|
@@ -20,9 +31,17 @@ export interface BreakdownProps {
|
|
|
20
31
|
* render an informational breakdown. */
|
|
21
32
|
onSelect?: (key: string | null) => void;
|
|
22
33
|
formatValue?: (n: number) => string;
|
|
34
|
+
/** Cap the visible rows; the rest fold behind a "show more" toggle. Keeps a
|
|
35
|
+
* long-tail breakdown a fixed height (so several align in a row) while staying
|
|
36
|
+
* fully drillable on expand. The stacked bar always reflects ALL items. Omit
|
|
37
|
+
* to show every row. */
|
|
38
|
+
maxRows?: number;
|
|
39
|
+
/** Expand/collapse labels (defaults to English). */
|
|
40
|
+
labels?: BreakdownLabels;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
const defaultFormat = (n: number): string => n.toLocaleString("en-US");
|
|
44
|
+
const DEFAULT_LABELS: BreakdownLabels = { more: (hidden) => `Show ${hidden} more`, less: "Show less" };
|
|
26
45
|
|
|
27
46
|
/**
|
|
28
47
|
* Composition browser for LARGE populations — the answer to "what is my
|
|
@@ -34,8 +53,15 @@ const defaultFormat = (n: number): string => n.toLocaleString("en-US");
|
|
|
34
53
|
* full faceted overview; selections combine in the host's filter state.
|
|
35
54
|
*/
|
|
36
55
|
export function Breakdown(props: BreakdownProps) {
|
|
37
|
-
const { items, selectedKey = null, onSelect, formatValue = defaultFormat } = props;
|
|
56
|
+
const { items, selectedKey = null, onSelect, formatValue = defaultFormat, maxRows, labels = DEFAULT_LABELS } = props;
|
|
38
57
|
const total = items.reduce((sum, item) => sum + item.value, 0);
|
|
58
|
+
const [expanded, setExpanded] = useState(false);
|
|
59
|
+
// Cap the rows when `maxRows` is set and the tail is longer; the bar still
|
|
60
|
+
// reflects every item, only the ranked list folds.
|
|
61
|
+
const cap = maxRows ?? items.length;
|
|
62
|
+
const collapsible = items.length > cap;
|
|
63
|
+
const hidden = items.length - cap;
|
|
64
|
+
const visible = collapsible && !expanded ? items.slice(0, cap) : items;
|
|
39
65
|
|
|
40
66
|
return (
|
|
41
67
|
<View style={styles.container}>
|
|
@@ -49,7 +75,7 @@ export function Breakdown(props: BreakdownProps) {
|
|
|
49
75
|
}))}
|
|
50
76
|
/>
|
|
51
77
|
<View>
|
|
52
|
-
{
|
|
78
|
+
{visible.map((item) => {
|
|
53
79
|
const share = total > 0 ? Math.round((item.value / total) * 100) : 0;
|
|
54
80
|
const selected = selectedKey === item.key;
|
|
55
81
|
const dimmed = selectedKey !== null && !selected;
|
|
@@ -84,6 +110,20 @@ export function Breakdown(props: BreakdownProps) {
|
|
|
84
110
|
</View>
|
|
85
111
|
);
|
|
86
112
|
})}
|
|
113
|
+
{collapsible ? (
|
|
114
|
+
<PressableHighlight
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityState={{ expanded }}
|
|
117
|
+
accessibilityLabel={expanded ? labels.less : labels.more(hidden)}
|
|
118
|
+
onPress={() => setExpanded((e) => !e)}
|
|
119
|
+
style={[styles.row, styles.pressable, styles.toggle]}
|
|
120
|
+
>
|
|
121
|
+
<Text size="sm" weight="medium" color="muted">
|
|
122
|
+
{expanded ? labels.less : labels.more(hidden)}
|
|
123
|
+
</Text>
|
|
124
|
+
<Icon name={expanded ? "chevron-up" : "chevron-down"} size={16} color={colors.zinc[400]} />
|
|
125
|
+
</PressableHighlight>
|
|
126
|
+
) : null}
|
|
87
127
|
</View>
|
|
88
128
|
</View>
|
|
89
129
|
);
|
|
@@ -96,7 +136,7 @@ const styles = StyleSheet.create({
|
|
|
96
136
|
row: {
|
|
97
137
|
flexDirection: "row",
|
|
98
138
|
alignItems: "center",
|
|
99
|
-
gap:
|
|
139
|
+
gap: 8,
|
|
100
140
|
minHeight: 36,
|
|
101
141
|
},
|
|
102
142
|
pressable: {
|
|
@@ -116,6 +156,10 @@ const styles = StyleSheet.create({
|
|
|
116
156
|
flex: 1,
|
|
117
157
|
},
|
|
118
158
|
share: {
|
|
119
|
-
width:
|
|
159
|
+
width: 30,
|
|
160
|
+
},
|
|
161
|
+
toggle: {
|
|
162
|
+
gap: 4,
|
|
163
|
+
minHeight: 32,
|
|
120
164
|
},
|
|
121
165
|
});
|
package/src/combobox.tsx
CHANGED
|
@@ -290,7 +290,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
290
290
|
|
|
291
291
|
return (
|
|
292
292
|
<View style={style}>
|
|
293
|
-
<View ref={triggerRef} style={multi ? [styles.multiBox,
|
|
293
|
+
<View ref={triggerRef} style={multi ? [styles.multiBox, showList && styles.openRing] : undefined}>
|
|
294
294
|
{multi
|
|
295
295
|
? chips.map((chip) => (
|
|
296
296
|
<View key={chip.value} style={styles.chip}>
|
|
@@ -342,7 +342,11 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
342
342
|
multi ? styles.multiInput : showChevron ? styles.selectInput : undefined,
|
|
343
343
|
// Single-select wears the open ring on the input (which holds the
|
|
344
344
|
// border); multi wears it on the wrapper above (the input is borderless).
|
|
345
|
-
|
|
345
|
+
// Gate on `showList`, not `open`: an empty query with no options has
|
|
346
|
+
// `open=true` but renders NO dropdown, so a ring tied to `open` would
|
|
347
|
+
// show with nothing below it AND linger after blur (no popover fires a
|
|
348
|
+
// close). The ring must mean "a dropdown is up".
|
|
349
|
+
!multi && showList ? styles.openRing : undefined,
|
|
346
350
|
]}
|
|
347
351
|
role="combobox"
|
|
348
352
|
aria-expanded={showList}
|
|
@@ -358,7 +362,14 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
358
362
|
</View>
|
|
359
363
|
<Popover
|
|
360
364
|
open={showList}
|
|
361
|
-
onOpenChange={
|
|
365
|
+
onOpenChange={(next) => {
|
|
366
|
+
setOpen(next);
|
|
367
|
+
// An outside dismiss closes the menu but leaves the textbox focused, so
|
|
368
|
+
// a keyboard `:focus-visible` ring would linger. Drop focus when the
|
|
369
|
+
// popover closes ITSELF — a commit refocuses through its own path
|
|
370
|
+
// (`commit` sets `open` directly), never through `onOpenChange`.
|
|
371
|
+
if (!next) inputRef.current?.blur();
|
|
372
|
+
}}
|
|
362
373
|
triggerRef={triggerRef}
|
|
363
374
|
side="bottom"
|
|
364
375
|
align="start"
|
package/src/search_input.tsx
CHANGED
|
@@ -49,25 +49,27 @@ export function SearchInput(props: SearchInputProps) {
|
|
|
49
49
|
onKeyPress={handleKeyPress}
|
|
50
50
|
icon="search"
|
|
51
51
|
clearable
|
|
52
|
-
style={styles.
|
|
52
|
+
style={styles.search}
|
|
53
53
|
/>
|
|
54
54
|
</View>
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const styles = StyleSheet.create({
|
|
59
|
-
// Search reads as a view-control by
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
// Search reads as a view-control by its leading search glyph + white fill —
|
|
60
|
+
// NOT by a distinct shape. It shares the rounded-8 radius of every other
|
|
61
|
+
// input/picker (TextInputField, Combobox, the date filter), so a toolbar of
|
|
62
|
+
// controls reads as one consistent band. The white fill still pops against
|
|
63
|
+
// the zinc-50 canvas where most toolbars live; a zinc-300 border (one step up
|
|
64
|
+
// from the form-input hairline) keeps it defined on white cards too, where
|
|
65
|
+
// the fill alone would vanish. 40px (TextInputField default) — the system
|
|
66
|
+
// control height every band aligns to.
|
|
67
|
+
search: {
|
|
68
|
+
borderRadius: 8,
|
|
67
69
|
backgroundColor: colors.white,
|
|
68
|
-
// A thin 1px resting border (TextInputField's default width) —
|
|
69
|
-
//
|
|
70
|
-
//
|
|
70
|
+
// A thin 1px resting border (TextInputField's default width) — interaction
|
|
71
|
+
// emphasis comes from the input's own focus outline, so it stays quiet at
|
|
72
|
+
// rest. zinc-300 keeps it defined on white cards.
|
|
71
73
|
borderColor: colors.zinc[300],
|
|
72
74
|
},
|
|
73
75
|
});
|