@lotics/ui 7.19.3 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +298 -159
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +48 -32
- package/examples/tpl_tasks.tsx +47 -47
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +9 -9
- package/src/agent_run.tsx +1 -1
- package/src/capture_row.tsx +59 -0
- package/src/change_review.tsx +732 -236
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/detail_row.tsx +137 -10
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +43 -16
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/sources.tsx +8 -5
- package/src/suggestion_chip.tsx +47 -0
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_triage.tsx +0 -112
- package/examples/tpl_wizard.tsx +0 -223
- package/src/discrepancy.tsx +0 -114
- package/src/match_sides.tsx +0 -79
- package/src/record_fields.tsx +0 -68
- package/src/review_card.tsx +0 -172
- package/src/scored_option.tsx +0 -138
- package/src/spec_list.tsx +0 -81
- package/src/triage_row.tsx +0 -99
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { useSectionNav } from "./use_section_nav";
|
|
5
|
+
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
|
|
6
|
+
|
|
7
|
+
const layoutAt = (y: number) => ({ nativeEvent: { layout: { y } } }) as LayoutChangeEvent;
|
|
8
|
+
const scrollTo = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as NativeSyntheticEvent<NativeScrollEvent>;
|
|
9
|
+
|
|
10
|
+
function mounted() {
|
|
11
|
+
const hook = renderHook(() => useSectionNav(["a", "b", "c"] as const));
|
|
12
|
+
act(() => {
|
|
13
|
+
hook.result.current.register("a")(layoutAt(0));
|
|
14
|
+
hook.result.current.register("b")(layoutAt(400));
|
|
15
|
+
hook.result.current.register("c")(layoutAt(900));
|
|
16
|
+
});
|
|
17
|
+
return hook;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("useSectionNav", () => {
|
|
21
|
+
it("starts on the first key", () => {
|
|
22
|
+
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
23
|
+
expect(result.current.activeKey).toBe("a");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("activates the last section whose top passed the viewport edge (+80 threshold)", () => {
|
|
27
|
+
const { result } = mounted();
|
|
28
|
+
act(() => result.current.onScroll(scrollTo(0)));
|
|
29
|
+
expect(result.current.activeKey).toBe("a");
|
|
30
|
+
// 321 + 80 threshold = 401 >= b's 400 → b is active
|
|
31
|
+
act(() => result.current.onScroll(scrollTo(321)));
|
|
32
|
+
expect(result.current.activeKey).toBe("b");
|
|
33
|
+
// just short of the threshold stays on a
|
|
34
|
+
act(() => result.current.onScroll(scrollTo(319)));
|
|
35
|
+
expect(result.current.activeKey).toBe("a");
|
|
36
|
+
act(() => result.current.onScroll(scrollTo(2000)));
|
|
37
|
+
expect(result.current.activeKey).toBe("c");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keys walk in page order — a later key with a smaller offset never wins", () => {
|
|
41
|
+
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
42
|
+
act(() => {
|
|
43
|
+
result.current.register("a")(layoutAt(500));
|
|
44
|
+
result.current.register("b")(layoutAt(100));
|
|
45
|
+
});
|
|
46
|
+
act(() => result.current.onScroll(scrollTo(600)));
|
|
47
|
+
// both tops passed; the LAST key in page order wins
|
|
48
|
+
expect(result.current.activeKey).toBe("b");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ignores keys that never registered", () => {
|
|
52
|
+
const { result } = renderHook(() => useSectionNav(["a", "b", "c"] as const));
|
|
53
|
+
act(() => result.current.register("a")(layoutAt(0)));
|
|
54
|
+
act(() => result.current.onScroll(scrollTo(999)));
|
|
55
|
+
expect(result.current.activeKey).toBe("a");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("jumpTo scrolls to the section's offset minus the 12px breathing room, clamped at 0", () => {
|
|
59
|
+
const { result } = mounted();
|
|
60
|
+
const calls: { y: number }[] = [];
|
|
61
|
+
(result.current.scrollRef as { current: unknown }).current = {
|
|
62
|
+
scrollTo: (opts: { y: number }) => calls.push(opts),
|
|
63
|
+
};
|
|
64
|
+
act(() => result.current.jumpTo("b"));
|
|
65
|
+
expect(calls[0]?.y).toBe(388);
|
|
66
|
+
act(() => result.current.jumpTo("a"));
|
|
67
|
+
expect(calls[1]?.y).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RefObject, useRef, useState } from "react";
|
|
2
|
+
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface SectionNavHandle<K extends string> {
|
|
5
|
+
/** Attach to the content `ScrollView` (with `onScroll` + `scrollEventThrottle={16}`). */
|
|
6
|
+
scrollRef: RefObject<ScrollView | null>;
|
|
7
|
+
/** The section the scroll currently sits in — drives the rail's `selected`. */
|
|
8
|
+
activeKey: K;
|
|
9
|
+
/** `onLayout={register(key)}` on each section wrapper. The wrapper must be a
|
|
10
|
+
* DIRECT child of the ScrollView content (layout.y is content-relative). */
|
|
11
|
+
register: (key: K) => (e: LayoutChangeEvent) => void;
|
|
12
|
+
/** Scroll to a section — the rail item's `onPress`. */
|
|
13
|
+
jumpTo: (key: K) => void;
|
|
14
|
+
onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scroll-spy for a LONG record surface with a left outline rail: one scrolling
|
|
19
|
+
* page whose sections register their offsets, a rail of `MenuButton`s that
|
|
20
|
+
* jumps to them, and an `activeKey` that follows the scroll (the section whose
|
|
21
|
+
* top has passed the viewport edge is the active one).
|
|
22
|
+
*
|
|
23
|
+
* const nav = useSectionNav(["details", "gatein", "gateout"] as const);
|
|
24
|
+
* <MenuButton title="Gate in" selected={nav.activeKey === "gatein"}
|
|
25
|
+
* onPress={() => nav.jumpTo("gatein")} />
|
|
26
|
+
* <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16}>
|
|
27
|
+
* <View onLayout={nav.register("gatein")}>…</View>
|
|
28
|
+
*
|
|
29
|
+
* Pass the keys in PAGE ORDER — the spy walks them top-down. Hide the rail on
|
|
30
|
+
* narrow containers (the page still scrolls; the rail is a wide-screen aid).
|
|
31
|
+
*
|
|
32
|
+
* Contract: every key's section stays MOUNTED (an unmounted section leaves its
|
|
33
|
+
* last offset registered — conditional sections belong inside an always-mounted
|
|
34
|
+
* wrapper that carries the `onLayout`), and offsets refresh only when a
|
|
35
|
+
* section's own layout changes — react-native-web's `onLayout` won't refire on
|
|
36
|
+
* a pure position shift, so content above the sections should settle before
|
|
37
|
+
* precision matters.
|
|
38
|
+
*/
|
|
39
|
+
export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
|
|
40
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
41
|
+
const sectionY = useRef<Partial<Record<K, number>>>({});
|
|
42
|
+
const [activeKey, setActiveKey] = useState<K>(keys[0]);
|
|
43
|
+
const register = (key: K) => (e: LayoutChangeEvent) => {
|
|
44
|
+
sectionY.current[key] = e.nativeEvent.layout.y;
|
|
45
|
+
};
|
|
46
|
+
const jumpTo = (key: K) => {
|
|
47
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, (sectionY.current[key] ?? 0) - 12), animated: true });
|
|
48
|
+
};
|
|
49
|
+
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
50
|
+
const y = e.nativeEvent.contentOffset.y + 80;
|
|
51
|
+
let cur: K = keys[0];
|
|
52
|
+
for (const k of keys) {
|
|
53
|
+
const sy = sectionY.current[k];
|
|
54
|
+
if (sy != null && sy <= y) cur = k;
|
|
55
|
+
}
|
|
56
|
+
if (cur !== activeKey) setActiveKey(cur);
|
|
57
|
+
};
|
|
58
|
+
return { scrollRef, activeKey, register, jumpTo, onScroll };
|
|
59
|
+
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
-
import { Text } from "@lotics/ui/text";
|
|
5
|
-
import { DotsIndicator } from "@lotics/ui/dots_indicator";
|
|
6
|
-
import { Composer } from "@lotics/ui/composer";
|
|
7
|
-
import { ChangeReview, ChangeReviewActions, ChangeDiff, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
|
|
8
|
-
|
|
9
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
-
// Template · AI assistant — a CHAT workflow, a different shape from the design
|
|
11
|
-
// canvas: a conversation where the agent's answer carries SUGGESTED EDITS the
|
|
12
|
-
// human approves or rejects ONE BY ONE (a before→after diff per change, ✓/✕),
|
|
13
|
-
// then applies only the accepted set. The review-each-edit pattern — an AI
|
|
14
|
-
// copilot proposing changes to a record, never committing on its own. All mock.
|
|
15
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
/** One field's before→after edit — the assistant's proposals, reviewed 1-by-1. */
|
|
18
|
-
interface EditChange {
|
|
19
|
-
label: string;
|
|
20
|
-
before?: string;
|
|
21
|
-
after: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface ChatMsg {
|
|
25
|
-
id: string;
|
|
26
|
-
role: "user" | "agent";
|
|
27
|
-
text?: string;
|
|
28
|
-
/** An agent turn may carry suggested edits to review 1-by-1. */
|
|
29
|
-
changes?: EditChange[];
|
|
30
|
-
applied?: boolean;
|
|
31
|
-
typing?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const SUGGESTED: EditChange[] = [
|
|
35
|
-
{ label: "Company", before: "acme packaging co", after: "Acme Packaging Co." },
|
|
36
|
-
{ label: "Phone", before: "0901234567", after: "+84 90 123 4567" },
|
|
37
|
-
{ label: "Email", before: "SALES@ACME.VN", after: "sales@acme.vn" },
|
|
38
|
-
{ label: "Industry", after: "Packaging" },
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
export function TplAssistant() {
|
|
42
|
-
const idRef = useRef(1);
|
|
43
|
-
const nextId = () => `m${idRef.current++}`;
|
|
44
|
-
const [messages, setMessages] = useState<ChatMsg[]>([
|
|
45
|
-
{ id: "m0", role: "agent", text: "Paste a record or ask me to tidy one up — I'll propose changes you can approve one by one." },
|
|
46
|
-
]);
|
|
47
|
-
const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
|
|
48
|
-
const [prompt, setPrompt] = useState("");
|
|
49
|
-
const [busy, setBusy] = useState(false);
|
|
50
|
-
const [proposed, setProposed] = useState(false);
|
|
51
|
-
|
|
52
|
-
const send = (text: string) => {
|
|
53
|
-
setPrompt("");
|
|
54
|
-
const userMsg: ChatMsg = { id: nextId(), role: "user", text };
|
|
55
|
-
const typingId = nextId();
|
|
56
|
-
setMessages((m) => [...m, userMsg, { id: typingId, role: "agent", typing: true }]);
|
|
57
|
-
setBusy(true);
|
|
58
|
-
setTimeout(() => {
|
|
59
|
-
setBusy(false);
|
|
60
|
-
setMessages((m) =>
|
|
61
|
-
m.map((msg) =>
|
|
62
|
-
msg.id === typingId
|
|
63
|
-
? proposed
|
|
64
|
-
? { id: msg.id, role: "agent", text: "Done — anything else to adjust?" }
|
|
65
|
-
: { id: msg.id, role: "agent", text: "Here's what I'd standardize on this contact. Review each:", changes: SUGGESTED.map((c) => ({ ...c, status: "pending" })) }
|
|
66
|
-
: msg,
|
|
67
|
-
),
|
|
68
|
-
);
|
|
69
|
-
setProposed(true);
|
|
70
|
-
}, 1300);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const decide = (msgId: string, index: number, status: ChangeReviewItemStatus) => {
|
|
74
|
-
setDecisions((d) => ({ ...d, [`${msgId}:${index}`]: status }));
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const applyMsg = (msgId: string) => {
|
|
78
|
-
const msg = messages.find((m) => m.id === msgId);
|
|
79
|
-
const n = (msg?.changes ?? []).filter((_, i) => decisions[`${msgId}:${i}`] === "accepted").length;
|
|
80
|
-
setMessages((m) => [
|
|
81
|
-
...m.map((x) => (x.id === msgId ? { ...x, applied: true } : x)),
|
|
82
|
-
{ id: nextId(), role: "agent", text: `Applied ${n} change${n === 1 ? "" : "s"} to the contact.` },
|
|
83
|
-
]);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<View style={{ flex: 1, backgroundColor: colors.white }}>
|
|
88
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
|
|
89
|
-
<View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
|
|
90
|
-
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
|
|
91
|
-
</View>
|
|
92
|
-
<Text size="sm" weight="semibold">Record assistant</Text>
|
|
93
|
-
</View>
|
|
94
|
-
|
|
95
|
-
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 140, gap: 16, maxWidth: 760, width: "100%", alignSelf: "center" }}>
|
|
96
|
-
{messages.map((m) => {
|
|
97
|
-
if (m.role === "user") {
|
|
98
|
-
return (
|
|
99
|
-
<View key={m.id} style={{ alignItems: "flex-end" }}>
|
|
100
|
-
<View style={{ maxWidth: "82%", backgroundColor: colors.zinc[900], borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10 }}>
|
|
101
|
-
<Text size="sm" color="inverted">{m.text}</Text>
|
|
102
|
-
</View>
|
|
103
|
-
</View>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
const active = m.changes != null && !m.applied;
|
|
107
|
-
return (
|
|
108
|
-
<View key={m.id} style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }}>
|
|
109
|
-
<View style={{ width: 26, height: 26, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100], marginTop: 1 }}>
|
|
110
|
-
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
|
|
111
|
-
</View>
|
|
112
|
-
<View style={{ flex: 1, gap: 10 }}>
|
|
113
|
-
{m.typing ? (
|
|
114
|
-
<View style={{ paddingVertical: 6 }}><DotsIndicator size={6} color={colors.zinc[400]} /></View>
|
|
115
|
-
) : null}
|
|
116
|
-
{m.text ? <Text size="sm">{m.text}</Text> : null}
|
|
117
|
-
{m.changes ? (
|
|
118
|
-
<View style={{ gap: 14 }}>
|
|
119
|
-
<ChangeReview
|
|
120
|
-
items={m.changes}
|
|
121
|
-
getKey={(c, i) => `${m.id}:${i}`}
|
|
122
|
-
statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
|
|
123
|
-
renderItem={(c) => (
|
|
124
|
-
<>
|
|
125
|
-
<Text size="xs" color="muted" weight="medium">{c.label}</Text>
|
|
126
|
-
<ChangeDiff before={c.before} after={c.after} />
|
|
127
|
-
</>
|
|
128
|
-
)}
|
|
129
|
-
renderSummary={(c, kept) => (
|
|
130
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
131
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{c.label}</Text>
|
|
132
|
-
<Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{kept ? c.after : (c.before ?? c.after)}</Text>
|
|
133
|
-
</View>
|
|
134
|
-
)}
|
|
135
|
-
status={m.applied ? "applied" : "open"}
|
|
136
|
-
onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
|
|
137
|
-
onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
|
|
138
|
-
onUndoItem={active ? (i) => setDecisions((d) => { const n = { ...d }; delete n[`${m.id}:${i}`]; return n; }) : undefined}
|
|
139
|
-
/>
|
|
140
|
-
{/* Inline (no dialog) — the commit bar sits under the list behind a divider. */}
|
|
141
|
-
{active && !m.applied ? (
|
|
142
|
-
<View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 14, flexDirection: "row" }}>
|
|
143
|
-
<ChangeReviewActions
|
|
144
|
-
items={m.changes}
|
|
145
|
-
statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
|
|
146
|
-
applyLabel="Apply accepted"
|
|
147
|
-
onAcceptItem={(i) => decide(m.id, i, "accepted")}
|
|
148
|
-
onApply={() => applyMsg(m.id)}
|
|
149
|
-
onDiscard={() => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x)))}
|
|
150
|
-
/>
|
|
151
|
-
</View>
|
|
152
|
-
) : null}
|
|
153
|
-
</View>
|
|
154
|
-
) : null}
|
|
155
|
-
</View>
|
|
156
|
-
</View>
|
|
157
|
-
);
|
|
158
|
-
})}
|
|
159
|
-
</ScrollView>
|
|
160
|
-
|
|
161
|
-
<View style={{ position: "absolute", left: 0, right: 0, bottom: 20, alignItems: "center", paddingHorizontal: 16 }}>
|
|
162
|
-
<View style={{ width: "100%", maxWidth: 720 }}>
|
|
163
|
-
<Composer
|
|
164
|
-
value={prompt}
|
|
165
|
-
onChangeText={setPrompt}
|
|
166
|
-
onSend={send}
|
|
167
|
-
disabled={busy}
|
|
168
|
-
placeholder={proposed ? "Ask for another change…" : "Try: “Tidy up this contact”"}
|
|
169
|
-
/>
|
|
170
|
-
</View>
|
|
171
|
-
</View>
|
|
172
|
-
</View>
|
|
173
|
-
);
|
|
174
|
-
}
|
package/examples/tpl_billing.tsx
DELETED
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors } from "@lotics/ui/colors";
|
|
5
|
-
import { Button } from "@lotics/ui/button";
|
|
6
|
-
import { Card, CardBody, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
|
-
import { Badge } from "@lotics/ui/badge";
|
|
8
|
-
import { Link } from "@lotics/ui/link";
|
|
9
|
-
import { Icon } from "@lotics/ui/icon";
|
|
10
|
-
import { Divider } from "@lotics/ui/divider";
|
|
11
|
-
import { NumberInput } from "@lotics/ui/number_input";
|
|
12
|
-
import { Picker } from "@lotics/ui/picker";
|
|
13
|
-
import type { PickerOption } from "@lotics/ui/picker";
|
|
14
|
-
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
15
|
-
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
16
|
-
import { Alert } from "@lotics/ui/alert";
|
|
17
|
-
import { formatMoney } from "@lotics/ui/format_money";
|
|
18
|
-
|
|
19
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
-
// Template · Billing & invoicing — the charges→invoice→collect flow as ONE
|
|
21
|
-
// banded card, not a stack of disconnected cards. It's a single document, so it
|
|
22
|
-
// reads as one: the bill-to, each invoice, the collect total, and the deposit
|
|
23
|
-
// are BANDS separated by hairline Dividers. The INVOICE DOCUMENT is the unit —
|
|
24
|
-
// each band is anchored by its own title + status badge and owns its editable
|
|
25
|
-
// charge lines (amount + how it's paid), its live total, and its issue action,
|
|
26
|
-
// so a charge never lives apart from the document it bills on. Issuing a real
|
|
27
|
-
// e-invoice is irreversible and needs a bill-to tax ID, so it's gated inline
|
|
28
|
-
// (never a dead end) and confirmed in a Dialog. A refundable DEPOSIT is its own
|
|
29
|
-
// (tinted) band — collected separately, never folded into the total due.
|
|
30
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
type Method = "cash" | "transfer" | "card";
|
|
33
|
-
const METHODS: PickerOption<Method>[] = [
|
|
34
|
-
{ value: "cash", label: "Cash" },
|
|
35
|
-
{ value: "transfer", label: "Bank transfer" },
|
|
36
|
-
{ value: "card", label: "Card" },
|
|
37
|
-
];
|
|
38
|
-
|
|
39
|
-
interface Charge {
|
|
40
|
-
key: string;
|
|
41
|
-
label: string;
|
|
42
|
-
/** The expected list price — offered as a one-tap fill while the line is unset. */
|
|
43
|
-
standard: number;
|
|
44
|
-
amount: number;
|
|
45
|
-
method: Method | "";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface Invoice {
|
|
49
|
-
key: string;
|
|
50
|
-
title: string;
|
|
51
|
-
charges: Charge[];
|
|
52
|
-
/** Lookup code once issued to the e-invoice provider; "" = not issued. */
|
|
53
|
-
ref: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const INITIAL: Invoice[] = [
|
|
57
|
-
{
|
|
58
|
-
key: "service",
|
|
59
|
-
title: "Service",
|
|
60
|
-
ref: "",
|
|
61
|
-
charges: [
|
|
62
|
-
{ key: "labor", label: "Labor", standard: 1_200_000, amount: 1_200_000, method: "cash" },
|
|
63
|
-
{ key: "parts", label: "Parts", standard: 0, amount: 0, method: "" },
|
|
64
|
-
],
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
key: "inspection",
|
|
68
|
-
title: "Inspection",
|
|
69
|
-
ref: "",
|
|
70
|
-
charges: [{ key: "insp", label: "Inspection fee", standard: 150_000, amount: 150_000, method: "" }],
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
key: "disposal",
|
|
74
|
-
title: "Disposal",
|
|
75
|
-
ref: "INV-2026-0414",
|
|
76
|
-
charges: [{ key: "disp", label: "Disposal fee", standard: 80_000, amount: 80_000, method: "transfer" }],
|
|
77
|
-
},
|
|
78
|
-
];
|
|
79
|
-
|
|
80
|
-
type Status = "none" | "draft" | "issued";
|
|
81
|
-
const invoiceTotal = (inv: Invoice) => inv.charges.reduce((s, c) => s + c.amount, 0);
|
|
82
|
-
const invoiceStatus = (inv: Invoice): Status => (inv.ref ? "issued" : invoiceTotal(inv) > 0 ? "draft" : "none");
|
|
83
|
-
const missingMethods = (inv: Invoice) => inv.charges.filter((c) => c.amount > 0 && !c.method);
|
|
84
|
-
|
|
85
|
-
/** One charge: label, an amount that reads blank until entered (a one-tap
|
|
86
|
-
* Standard fill + "Set 0" while unset), and the payment method — which turns
|
|
87
|
-
* required the moment the line carries an amount. */
|
|
88
|
-
function ChargeLine({
|
|
89
|
-
charge, onAmount, onMethod, disabled,
|
|
90
|
-
}: {
|
|
91
|
-
charge: Charge;
|
|
92
|
-
onAmount: (v: number) => void;
|
|
93
|
-
onMethod: (m: Method) => void;
|
|
94
|
-
disabled: boolean;
|
|
95
|
-
}) {
|
|
96
|
-
const unset = charge.amount <= 0;
|
|
97
|
-
const needsMethod = charge.amount > 0 && !charge.method;
|
|
98
|
-
return (
|
|
99
|
-
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10, flexWrap: "wrap" }}>
|
|
100
|
-
<Text size="sm" color="muted" style={{ width: 116, paddingTop: 10 }}>{charge.label}</Text>
|
|
101
|
-
<View style={{ flexGrow: 1, flexBasis: 130, gap: 4 }}>
|
|
102
|
-
<NumberInput
|
|
103
|
-
value={unset ? null : charge.amount}
|
|
104
|
-
onValueChange={(v) => onAmount(v ?? 0)}
|
|
105
|
-
min={0}
|
|
106
|
-
disabled={disabled}
|
|
107
|
-
accessibilityLabel={`${charge.label} amount`}
|
|
108
|
-
/>
|
|
109
|
-
{unset && !disabled ? (
|
|
110
|
-
<View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap" }}>
|
|
111
|
-
{charge.standard > 0 ? (
|
|
112
|
-
<Button title={`Standard ${formatMoney(charge.standard)}`} color="secondary" onPress={() => onAmount(charge.standard)} />
|
|
113
|
-
) : null}
|
|
114
|
-
<Button title="Set 0" color="secondary" onPress={() => onAmount(0)} />
|
|
115
|
-
</View>
|
|
116
|
-
) : null}
|
|
117
|
-
</View>
|
|
118
|
-
<View style={{ flexBasis: 168, gap: 4 }}>
|
|
119
|
-
<Picker
|
|
120
|
-
options={METHODS}
|
|
121
|
-
value={charge.method || null}
|
|
122
|
-
onValueChange={onMethod}
|
|
123
|
-
placeholder="How paid…"
|
|
124
|
-
disabled={disabled || unset}
|
|
125
|
-
accessibilityLabel={`Payment method · ${charge.label}`}
|
|
126
|
-
/>
|
|
127
|
-
{needsMethod ? <Text size="xs" color="danger">Choose a method</Text> : null}
|
|
128
|
-
</View>
|
|
129
|
-
</View>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** One invoice document, rendered as a BAND inside the single billing card: a
|
|
134
|
-
* title + status header, its charge lines, and a total + issue row. The leading
|
|
135
|
-
* Divider sets it off from the band above without a second card. */
|
|
136
|
-
function InvoiceBand({
|
|
137
|
-
inv, taxId, onAmount, onMethod, onIssue,
|
|
138
|
-
}: {
|
|
139
|
-
inv: Invoice;
|
|
140
|
-
taxId: string;
|
|
141
|
-
onAmount: (chKey: string, v: number) => void;
|
|
142
|
-
onMethod: (chKey: string, m: Method) => void;
|
|
143
|
-
onIssue: (inv: Invoice) => void;
|
|
144
|
-
}) {
|
|
145
|
-
const total = invoiceTotal(inv);
|
|
146
|
-
const status = invoiceStatus(inv);
|
|
147
|
-
const missing = missingMethods(inv);
|
|
148
|
-
const canIssue = total > 0 && missing.length === 0 && !!taxId;
|
|
149
|
-
const blockReason = !taxId ? "Add the customer's tax ID to issue." : missing.length ? "Choose a payment method for every charged line." : "";
|
|
150
|
-
return (
|
|
151
|
-
<>
|
|
152
|
-
<Divider />
|
|
153
|
-
<CardBody style={{ gap: 10 }}>
|
|
154
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
155
|
-
<Text weight="semibold">{inv.title}</Text>
|
|
156
|
-
<View style={{ flex: 1 }} />
|
|
157
|
-
{status === "issued" ? (
|
|
158
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
159
|
-
<Badge variant="dot" label="Issued" color="emerald" />
|
|
160
|
-
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
161
|
-
</View>
|
|
162
|
-
) : status === "draft" ? (
|
|
163
|
-
<Badge variant="dot" label="Draft" color="amber" />
|
|
164
|
-
) : (
|
|
165
|
-
<Badge variant="dot" label="Nothing to bill" color="zinc" />
|
|
166
|
-
)}
|
|
167
|
-
</View>
|
|
168
|
-
{inv.charges.map((c) => (
|
|
169
|
-
<ChargeLine
|
|
170
|
-
key={c.key}
|
|
171
|
-
charge={c}
|
|
172
|
-
disabled={status === "issued"}
|
|
173
|
-
onAmount={(v) => onAmount(c.key, v)}
|
|
174
|
-
onMethod={(m) => onMethod(c.key, m)}
|
|
175
|
-
/>
|
|
176
|
-
))}
|
|
177
|
-
{total > 0 ? (
|
|
178
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", paddingTop: 2 }}>
|
|
179
|
-
<Text size="sm" color="muted" style={{ flex: 1 }}>
|
|
180
|
-
Total <Text weight="semibold" color="default" tabular>{formatMoney(total)}</Text>
|
|
181
|
-
</Text>
|
|
182
|
-
{status === "issued" ? (
|
|
183
|
-
<>
|
|
184
|
-
<Button title="Re-issue" color="secondary" disabled={!canIssue} onPress={() => onIssue(inv)} />
|
|
185
|
-
</>
|
|
186
|
-
) : (
|
|
187
|
-
<Button title="Issue invoice" color="primary" disabled={!canIssue} onPress={() => onIssue(inv)} />
|
|
188
|
-
)}
|
|
189
|
-
</View>
|
|
190
|
-
) : null}
|
|
191
|
-
{total > 0 && status !== "issued" && !canIssue ? <Text size="xs" color="muted">{blockReason}</Text> : null}
|
|
192
|
-
</CardBody>
|
|
193
|
-
</>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export function TplBilling() {
|
|
198
|
-
const [invoices, setInvoices] = useState<Invoice[]>(INITIAL);
|
|
199
|
-
// The bill-to: issuing an e-invoice needs a tax ID. Toggle it to see the gate.
|
|
200
|
-
const [taxId, setTaxId] = useState("0312456780");
|
|
201
|
-
const [deposit, setDeposit] = useState(0);
|
|
202
|
-
const [confirm, setConfirm] = useState<Invoice | null>(null);
|
|
203
|
-
const seq = useState(() => ({ n: 414 }))[0];
|
|
204
|
-
|
|
205
|
-
const patchCharge = (invKey: string, chKey: string, patch: Partial<Charge>) =>
|
|
206
|
-
setInvoices((prev) =>
|
|
207
|
-
prev.map((inv) =>
|
|
208
|
-
inv.key !== invKey ? inv : { ...inv, charges: inv.charges.map((c) => (c.key === chKey ? { ...c, ...patch } : c)) },
|
|
209
|
-
),
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const grandTotal = invoices.reduce((s, inv) => s + invoiceTotal(inv), 0);
|
|
213
|
-
const allMissing = invoices.flatMap(missingMethods);
|
|
214
|
-
const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
|
|
215
|
-
|
|
216
|
-
const issue = (inv: Invoice) => {
|
|
217
|
-
seq.n += 1;
|
|
218
|
-
const ref = `INV-2026-${String(seq.n).padStart(4, "0")}`;
|
|
219
|
-
setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
|
|
220
|
-
setConfirm(null);
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const printReceipt = () => {
|
|
224
|
-
if (allMissing.length > 0) {
|
|
225
|
-
Alert.alert(
|
|
226
|
-
"Missing payment method",
|
|
227
|
-
`Choose how these charges were paid before printing the receipt:\n\n${allMissing.map((c) => `• ${c.label} (${formatMoney(c.amount)})`).join("\n")}`,
|
|
228
|
-
[{ text: "OK" }],
|
|
229
|
-
);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
237
|
-
<View style={{ width: "100%", maxWidth: 640, alignSelf: "center", gap: 16 }}>
|
|
238
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
239
|
-
<Text size="xl" weight="semibold">Billing</Text>
|
|
240
|
-
<Badge label="Order #4821" color="zinc" />
|
|
241
|
-
</View>
|
|
242
|
-
|
|
243
|
-
{/* ONE banded card — bill-to · each invoice · collect · deposit */}
|
|
244
|
-
<Card style={{ padding: 0 }}>
|
|
245
|
-
<CardHeader>
|
|
246
|
-
<CardHeaderTitle info="One document: the bill-to, each invoice, the total, and the deposit are bands of the same card.">
|
|
247
|
-
Phí & hóa đơn
|
|
248
|
-
</CardHeaderTitle>
|
|
249
|
-
<CardHeaderMeta>{formatMoney(grandTotal)}</CardHeaderMeta>
|
|
250
|
-
</CardHeader>
|
|
251
|
-
|
|
252
|
-
{/* bill-to band — issuing needs a tax ID; the toggle shows the gate */}
|
|
253
|
-
<CardBody>
|
|
254
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
255
|
-
<Icon name="building-2" size={16} color={colors.zinc[500]} />
|
|
256
|
-
<Text weight="medium">Harbor Freight Lines</Text>
|
|
257
|
-
<Text size="sm" color="muted">·</Text>
|
|
258
|
-
{taxId ? (
|
|
259
|
-
<Text size="sm" color="muted" tabular>MST {taxId}</Text>
|
|
260
|
-
) : (
|
|
261
|
-
<Text size="sm" color="danger">No tax ID — required to issue</Text>
|
|
262
|
-
)}
|
|
263
|
-
<View style={{ flex: 1 }} />
|
|
264
|
-
<Button title={taxId ? "Remove tax ID" : "Add tax ID"} color="muted" onPress={() => setTaxId((t) => (t ? "" : "0312456780"))} />
|
|
265
|
-
</View>
|
|
266
|
-
</CardBody>
|
|
267
|
-
|
|
268
|
-
{/* invoice bands */}
|
|
269
|
-
{invoices.map((inv) => (
|
|
270
|
-
<InvoiceBand
|
|
271
|
-
key={inv.key}
|
|
272
|
-
inv={inv}
|
|
273
|
-
taxId={taxId}
|
|
274
|
-
onAmount={(chKey, v) => patchCharge(inv.key, chKey, { amount: v })}
|
|
275
|
-
onMethod={(chKey, m) => patchCharge(inv.key, chKey, { method: m })}
|
|
276
|
-
onIssue={setConfirm}
|
|
277
|
-
/>
|
|
278
|
-
))}
|
|
279
|
-
|
|
280
|
-
{/* collect band */}
|
|
281
|
-
<Divider />
|
|
282
|
-
<CardBody style={{ gap: 10 }}>
|
|
283
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
284
|
-
<Text weight="semibold" style={{ flex: 1 }}>Tổng thu</Text>
|
|
285
|
-
<Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
|
|
286
|
-
</View>
|
|
287
|
-
{allMissing.length > 0 ? (
|
|
288
|
-
<Callout tone="warning">
|
|
289
|
-
<CalloutText>
|
|
290
|
-
{allMissing.length} charged {allMissing.length === 1 ? "line has" : "lines have"} no payment method — set them before printing the receipt.
|
|
291
|
-
</CalloutText>
|
|
292
|
-
</Callout>
|
|
293
|
-
) : null}
|
|
294
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
295
|
-
<Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
|
|
296
|
-
<Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
|
|
297
|
-
</View>
|
|
298
|
-
</CardBody>
|
|
299
|
-
|
|
300
|
-
{/* deposit band — tinted + labelled separate, never part of the total */}
|
|
301
|
-
<Divider />
|
|
302
|
-
<CardBody style={{ backgroundColor: colors.white, gap: 8 }}>
|
|
303
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
304
|
-
<Text weight="semibold">Deposit</Text>
|
|
305
|
-
<Badge label="Collected separately" color="zinc" />
|
|
306
|
-
</View>
|
|
307
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
|
308
|
-
<Text size="sm" color="muted" style={{ width: 116 }}>Refundable</Text>
|
|
309
|
-
<View style={{ flexGrow: 1, flexBasis: 130 }}>
|
|
310
|
-
<NumberInput value={deposit || null} onValueChange={(v) => setDeposit(v ?? 0)} min={0} accessibilityLabel="Deposit amount" />
|
|
311
|
-
</View>
|
|
312
|
-
<Button
|
|
313
|
-
title="Deposit receipt"
|
|
314
|
-
color="secondary"
|
|
315
|
-
disabled={deposit <= 0}
|
|
316
|
-
onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
|
|
317
|
-
/>
|
|
318
|
-
</View>
|
|
319
|
-
</CardBody>
|
|
320
|
-
</Card>
|
|
321
|
-
</View>
|
|
322
|
-
|
|
323
|
-
{/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
|
|
324
|
-
<Dialog width={460} open={confirm !== null} onOpenChange={(o) => { if (!o) setConfirm(null); }}>
|
|
325
|
-
<DialogHeader>
|
|
326
|
-
<DialogHeaderTitle>{confirm?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
|
|
327
|
-
</DialogHeader>
|
|
328
|
-
<View style={{ paddingHorizontal: 24, paddingVertical: 12 }}>
|
|
329
|
-
<Callout tone="warning">
|
|
330
|
-
<CalloutText>
|
|
331
|
-
{confirm
|
|
332
|
-
? `Issue a real e-invoice for "${confirm.title}" (${formatMoney(invoiceTotal(confirm))}) to the provider. This writes a lookup code to the record and can't be undone.`
|
|
333
|
-
: ""}
|
|
334
|
-
</CalloutText>
|
|
335
|
-
</Callout>
|
|
336
|
-
</View>
|
|
337
|
-
<DialogFooter>
|
|
338
|
-
<Button title="Cancel" color="secondary" onPress={() => setConfirm(null)} />
|
|
339
|
-
<Button title="Issue" color="primary" onPress={() => confirm && issue(confirm)} />
|
|
340
|
-
</DialogFooter>
|
|
341
|
-
</Dialog>
|
|
342
|
-
</ScrollView>
|
|
343
|
-
);
|
|
344
|
-
}
|