@lotics/ui 46.2.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.
Files changed (49) hide show
  1. package/AGENTS.md +38 -1
  2. package/MIGRATION.md +87 -0
  3. package/docs/ai_patterns.md +11 -0
  4. package/docs/catalog.md +217 -21
  5. package/docs/composition.md +74 -6
  6. package/docs/data_entry.md +56 -2
  7. package/docs/reviewing.md +34 -0
  8. package/docs/templates.md +93 -30
  9. package/docs/testing.md +6 -0
  10. package/examples/tpl_board.tsx +257 -0
  11. package/examples/tpl_money.tsx +1027 -0
  12. package/package.json +261 -258
  13. package/src/accordion.tsx +7 -1
  14. package/src/alert.css +0 -1
  15. package/src/alert.tsx +8 -0
  16. package/src/axis_label_indices.ts +84 -0
  17. package/src/bar_chart.tsx +137 -16
  18. package/src/board.tsx +611 -0
  19. package/src/card.tsx +7 -1
  20. package/src/charge_lines.tsx +373 -0
  21. package/src/chip_group.tsx +57 -1
  22. package/src/dialog.tsx +46 -24
  23. package/src/drawer.tsx +21 -2
  24. package/src/file_gallery_modal.tsx +3 -0
  25. package/src/file_row.tsx +98 -5
  26. package/src/icon.tsx +6 -0
  27. package/src/inline_edit.tsx +54 -10
  28. package/src/inline_number_input.tsx +5 -1
  29. package/src/inline_text_input.tsx +1 -1
  30. package/src/line_chart.tsx +2 -2
  31. package/src/locale.tsx +26 -1
  32. package/src/matrix.tsx +23 -8
  33. package/src/modal.tsx +23 -3
  34. package/src/overlay_layer.ts +65 -0
  35. package/src/page_content.tsx +8 -22
  36. package/src/page_header.tsx +60 -11
  37. package/src/popover.tsx +29 -5
  38. package/src/reference_field.tsx +36 -13
  39. package/src/skip_link.tsx +2 -1
  40. package/src/stacked_bar_chart.tsx +31 -1
  41. package/src/table.tsx +6 -1
  42. package/src/tabs.tsx +1 -1
  43. package/src/text.tsx +21 -0
  44. package/src/tooltip.tsx +2 -1
  45. package/src/use_change_set.ts +66 -17
  46. package/src/use_scroll_seam.ts +79 -0
  47. package/examples/tpl_report.tsx +0 -410
  48. package/examples/tpl_statements.tsx +0 -221
  49. package/src/line_chart_labels.ts +0 -32
@@ -39,22 +39,52 @@ export interface ChangeSet<Id extends string = string> {
39
39
  settled: boolean;
40
40
  }
41
41
 
42
- export interface UseChangeSetOptions {
42
+ export interface UseChangeSetOptions<Id extends string = string> {
43
43
  /**
44
44
  * What an untouched proposal counts as. Default `accepted`: the operator
45
45
  * drops the exceptions rather than approving each of eight identical lines,
46
46
  * which is the difference between a review and a second round of data entry.
47
47
  * Use `pending` when each change genuinely deserves its own verdict — and
48
48
  * gate the commit on `settled`.
49
+ *
50
+ * **A MAP when one set mixes kinds with different safe defaults** — filling a
51
+ * blank arrives `accepted` while overwriting a value a human already set
52
+ * arrives `rejected`, so the destructive half is opt-in. An id the map does
53
+ * not name arrives `accepted`. Deciding those rows by calling `reject()` from
54
+ * the result handler instead writes overrides the operator never made, and
55
+ * `undo` on such a row then returns it to `accepted` rather than to the safe
56
+ * default it was supposed to arrive at.
57
+ *
58
+ * A VALUE and not a predicate, so the default is a dependency like any other:
59
+ * `status` and the group arrays are derived from it together and change
60
+ * identity together when it changes. The caller closes over its own rows to
61
+ * build it (`useMemo` over the same rows it already has), which is what keeps
62
+ * this hook ignorant of what a proposal IS. A predicate written inline is a
63
+ * new function every render, so it can only be honoured by hiding it from the
64
+ * dependency lists — and a `status` that never changes identity is a `status`
65
+ * a memoizing screen reads once and then never again, which is the row
66
+ * rendering as kept while the commit bar counts it as dropped.
49
67
  */
50
- initial?: ChangeDecision;
68
+ initial?: ChangeDecision | ReadonlyMap<Id, ChangeDecision>;
69
+ }
70
+
71
+ /** The default for one id: the whole set's decision, or the map's entry for it,
72
+ * or `accepted` for an id nobody named. */
73
+ function defaultFor<Id extends string>(
74
+ initial: ChangeDecision | ReadonlyMap<Id, ChangeDecision> | undefined,
75
+ id: Id,
76
+ ): ChangeDecision {
77
+ if (initial === undefined) return "accepted";
78
+ if (typeof initial === "string") return initial;
79
+ return initial.get(id) ?? "accepted";
51
80
  }
52
81
 
53
82
  export function useChangeSet<Id extends string = string>(
54
83
  ids: readonly Id[],
55
- options?: UseChangeSetOptions,
84
+ options?: UseChangeSetOptions<Id>,
56
85
  ): ChangeSet<Id> {
57
- const initial = options?.initial ?? "accepted";
86
+ const initial = options?.initial;
87
+
58
88
  const [overrides, setOverrides] = useState<ReadonlyMap<Id, ChangeDecision>>(new Map());
59
89
 
60
90
  const set = useCallback((id: Id, decision: ChangeDecision) => {
@@ -79,21 +109,40 @@ export function useChangeSet<Id extends string = string>(
79
109
  [ids],
80
110
  );
81
111
 
82
- const status = useCallback((id: Id) => overrides.get(id) ?? initial, [overrides, initial]);
83
-
84
- const groups = useMemo(() => {
112
+ // ONE derivation, feeding BOTH readers of the same fact.
113
+ //
114
+ // Deriving the groups and `status` separately is what split them: they read
115
+ // the same three inputs twice, and the moment one of those inputs was kept out
116
+ // of a dependency list the two answered differently — `status(id)` said
117
+ // "rejected" while `accepted`, `keptCount` and the commit still carried the
118
+ // row. One memo over the same deps cannot do that. There is nothing expensive
119
+ // here either way: it is one pass over ids, the same pass `status` used to
120
+ // make per call.
121
+ const derived = useMemo(() => {
122
+ const decisions = new Map<Id, ChangeDecision>();
85
123
  const accepted: Id[] = [];
86
124
  const rejected: Id[] = [];
87
125
  const pending: Id[] = [];
88
126
  for (const id of ids) {
89
- const s = overrides.get(id) ?? initial;
90
- if (s === "accepted") accepted.push(id);
91
- else if (s === "rejected") rejected.push(id);
127
+ const decision = overrides.get(id) ?? defaultFor(initial, id);
128
+ decisions.set(id, decision);
129
+ if (decision === "accepted") accepted.push(id);
130
+ else if (decision === "rejected") rejected.push(id);
92
131
  else pending.push(id);
93
132
  }
94
- return { accepted, rejected, pending };
133
+ return { decisions, accepted, rejected, pending };
95
134
  }, [ids, overrides, initial]);
96
135
 
136
+ // Reads the SAME map the groups were built from, so the two cannot disagree,
137
+ // and changes identity whenever an answer does — a screen that memoizes its
138
+ // rows on `status` re-runs when a decision moves. An id outside `ids` still
139
+ // answers (its own override, else the default): a row can leave the set while
140
+ // a handler still holds its id.
141
+ const status = useCallback(
142
+ (id: Id) => derived.decisions.get(id) ?? overrides.get(id) ?? defaultFor(initial, id),
143
+ [derived, overrides, initial],
144
+ );
145
+
97
146
  return useMemo(
98
147
  () => ({
99
148
  status,
@@ -103,13 +152,13 @@ export function useChangeSet<Id extends string = string>(
103
152
  acceptAll: () => all("accepted"),
104
153
  rejectAll: () => all("rejected"),
105
154
  reset: () => setOverrides(new Map()),
106
- accepted: groups.accepted,
107
- rejected: groups.rejected,
108
- pending: groups.pending,
109
- keptCount: groups.accepted.length,
155
+ accepted: derived.accepted,
156
+ rejected: derived.rejected,
157
+ pending: derived.pending,
158
+ keptCount: derived.accepted.length,
110
159
  total: ids.length,
111
- settled: groups.pending.length === 0,
160
+ settled: derived.pending.length === 0,
112
161
  }),
113
- [status, set, undo, all, groups, ids.length],
162
+ [status, set, undo, all, derived, ids.length],
114
163
  );
115
164
  }
@@ -0,0 +1,79 @@
1
+ import { useCallback, useLayoutEffect, useRef } from "react";
2
+ import type { NativeScrollEvent, NativeSyntheticEvent, ScrollView } from "react-native";
3
+
4
+ /** The three props a seam needs on the `ScrollView` it manages. Spread, so a
5
+ * surface cannot wire half of it. */
6
+ export interface ScrollSeam {
7
+ ref: React.RefObject<ScrollView | null>;
8
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
9
+ scrollEventThrottle: number;
10
+ }
11
+
12
+ /**
13
+ * The identity of the content while nothing is swapped in.
14
+ *
15
+ * `scrollKey` is optional, and the natural call site — `scrollKey={openChild?.id}`,
16
+ * which is what the master-detail recipe prescribes — passes `undefined` for the
17
+ * MASTER leg. Reading that as "this surface opts out" broke exactly half of the
18
+ * seam: the forward leg worked (undefined → "child" is a change, so the child
19
+ * opened at 0) while the return leg restored nothing, because the master's
20
+ * offset had never been recorded under any key. The reader came back to the
21
+ * CHILD's offset inside the parent list — worse than either 0 or the remembered
22
+ * place.
23
+ *
24
+ * So an absent key is a KEY, not an opt-out. Opting out needs no signal of its
25
+ * own: a surface that never changes its key never scrolls, because the seam
26
+ * fires on the CHANGE and there is none.
27
+ */
28
+ const ROOT_KEY = "\u0000root";
29
+
30
+ /**
31
+ * REMEMBERED SCROLL OFFSETS ACROSS A CONTENT SWAP.
32
+ *
33
+ * A scroll container keeps its offset when its children change, because nothing
34
+ * tells it the content it was holding no longer exists. Swap a drawer's body for
35
+ * a child record and the new record opens part-way down itself, with its own
36
+ * heading off-screen above — invisible until the first list long enough to
37
+ * scroll, which is the same list that makes the swap worth having.
38
+ *
39
+ * The seam is the content's IDENTITY: while `scrollKey` holds, the container is
40
+ * left alone; when it changes, the outgoing key's offset is already recorded and
41
+ * the incoming one is restored — 0 for a key never seen, so a swap FORWARD opens
42
+ * at the top, and the remembered offset on the way BACK, so the reader keeps
43
+ * their place in the list they came from.
44
+ *
45
+ * That return leg is the whole reason this is not a React `key` on the scroll
46
+ * area. A key throws the container away and rebuilds it, which resets the child
47
+ * correctly and resets the PARENT just as thoroughly.
48
+ *
49
+ * No key CHANGE ⇒ no scrolling: a surface that does not swap content behaves
50
+ * exactly as it did before, whether it names its content or not. An ABSENT key
51
+ * is the root content's identity (see {@link ROOT_KEY}), not a request to skip
52
+ * the seam — which is what `scrollKey={openChild?.id}` needs on the leg where
53
+ * no child is open.
54
+ */
55
+ export function useScrollSeam(scrollKey: string | undefined): ScrollSeam {
56
+ const key = scrollKey ?? ROOT_KEY;
57
+ const ref = useRef<ScrollView | null>(null);
58
+ const offsets = useRef(new Map<string, number>());
59
+ // Which key the live offset belongs to. Read by `onScroll`, which fires long
60
+ // after the render that changed the prop.
61
+ const liveKey = useRef(key);
62
+
63
+ const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
64
+ offsets.current.set(liveKey.current, event.nativeEvent.contentOffset.y);
65
+ }, []);
66
+
67
+ useLayoutEffect(() => {
68
+ const previous = liveKey.current;
69
+ liveKey.current = key;
70
+ // On mount `previous` IS the key, so nothing is scrolled — a fresh container
71
+ // is already at the top and moving it would be a visible jump on open.
72
+ if (previous === key) return;
73
+ ref.current?.scrollTo({ y: offsets.current.get(key) ?? 0, animated: false });
74
+ }, [key]);
75
+
76
+ // Native emits one scroll event per gesture without this, so the offset the
77
+ // seam remembers would be wherever the finger first landed.
78
+ return { ref, onScroll, scrollEventThrottle: 16 };
79
+ }
@@ -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
- }