@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.
- package/AGENTS.md +38 -1
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +11 -0
- package/docs/catalog.md +217 -21
- package/docs/composition.md +74 -6
- package/docs/data_entry.md +56 -2
- package/docs/reviewing.md +34 -0
- package/docs/templates.md +93 -30
- package/docs/testing.md +6 -0
- package/examples/tpl_board.tsx +257 -0
- package/examples/tpl_money.tsx +1027 -0
- package/package.json +261 -258
- package/src/accordion.tsx +7 -1
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- 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/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- 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/line_chart.tsx +2 -2
- package/src/locale.tsx +26 -1
- package/src/matrix.tsx +23 -8
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/reference_field.tsx +36 -13
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/table.tsx +6 -1
- package/src/tabs.tsx +1 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/examples/tpl_report.tsx +0 -410
- package/examples/tpl_statements.tsx +0 -221
- package/src/line_chart_labels.ts +0 -32
package/src/use_change_set.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
107
|
-
rejected:
|
|
108
|
-
pending:
|
|
109
|
-
keptCount:
|
|
155
|
+
accepted: derived.accepted,
|
|
156
|
+
rejected: derived.rejected,
|
|
157
|
+
pending: derived.pending,
|
|
158
|
+
keptCount: derived.accepted.length,
|
|
110
159
|
total: ids.length,
|
|
111
|
-
settled:
|
|
160
|
+
settled: derived.pending.length === 0,
|
|
112
161
|
}),
|
|
113
|
-
[status, set, undo, all,
|
|
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
|
+
}
|
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
|
-
}
|