@lotics/ui 7.7.0 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/AGENTS.md +75 -33
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record.tsx +1 -1
  26. package/examples/tpl_record_plain.tsx +1 -1
  27. package/examples/tpl_report.tsx +1 -1
  28. package/examples/tpl_rollup.tsx +1 -1
  29. package/examples/tpl_settings.tsx +1 -1
  30. package/examples/tpl_shifts.tsx +1 -1
  31. package/examples/tpl_stock.tsx +1 -1
  32. package/examples/tpl_task_board.tsx +2 -2
  33. package/examples/tpl_tasks.tsx +1 -1
  34. package/examples/tpl_timeline.tsx +1 -1
  35. package/examples/tpl_tower.tsx +1 -1
  36. package/examples/tpl_triage.tsx +1 -1
  37. package/examples/tpl_wizard.tsx +1 -1
  38. package/package.json +3 -1
  39. package/src/card.tsx +6 -13
  40. package/src/combobox.tsx +28 -11
  41. package/src/data_grid.tsx +1 -1
  42. package/src/kpi_strip.tsx +5 -5
  43. package/src/pressable_row.tsx +22 -10
  44. package/src/sort_header.tsx +9 -4
  45. package/src/summary_line.tsx +76 -0
  46. package/src/table.tsx +27 -11
  47. package/src/use_selection.test.ts +66 -0
  48. package/src/use_selection.ts +75 -0
  49. package/examples/app_orders.tsx +0 -405
  50. package/examples/tpl_approvals.tsx +0 -375
  51. package/examples/tpl_batch.tsx +0 -234
  52. package/examples/tpl_callsheet.tsx +0 -481
  53. package/examples/tpl_convert.tsx +0 -478
  54. package/examples/tpl_crm_desk.tsx +0 -541
  55. package/examples/tpl_dispatch.tsx +0 -289
  56. package/examples/tpl_inventory.tsx +0 -299
  57. package/examples/tpl_pipeline.tsx +0 -400
  58. package/examples/tpl_reconcile.tsx +0 -275
  59. package/examples/tpl_run.tsx +0 -235
@@ -1,400 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
2
- import { ScrollView, StyleSheet, View } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { colors, solid, asColorName } from "@lotics/ui/colors";
5
- import { OptionBadge, type OptionValue } from "@lotics/ui/option_badge";
6
- import { MemberChip } from "@lotics/ui/member_chip";
7
- import { Avatar } from "@lotics/ui/avatar";
8
- import { SearchInput } from "@lotics/ui/search_input";
9
- import { FilterChip } from "@lotics/ui/filter_chip";
10
- import { OptionList } from "@lotics/ui/option_list";
11
- import { Button } from "@lotics/ui/button";
12
- import { InlineTextInput } from "@lotics/ui/inline_text_input";
13
- import { InlineNumberInput } from "@lotics/ui/inline_number_input";
14
- import { InlineMemberSelect } from "@lotics/ui/inline_member_select";
15
- import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
16
- import { InlineSelect } from "@lotics/ui/inline_select";
17
- import { TextInputField } from "@lotics/ui/text_input_field";
18
- import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
19
- import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
20
- import { Composer } from "@lotics/ui/composer";
21
- import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
22
- import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
23
- import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
24
- import { CompletionState } from "@lotics/ui/completion_state";
25
- import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
26
- import { formatMoney } from "@lotics/ui/format_money";
27
- import { toISODate } from "@lotics/ui/format_date";
28
-
29
- // ─────────────────────────────────────────────────────────────────────────────
30
- // Template · Deal pipeline — the SECOND consumer of the `DataGrid` primitive (the
31
- // inline-managed grouped table), sibling of tpl_task_board. Same grammar — toolbar
32
- // (search · group-by · filters) over a grouped, sortable, inline-editable grid,
33
- // per-group add — but a DIFFERENT domain and DIFFERENT FIELDS: a money value
34
- // (`InlineNumberInput`), and NO leading slot (a deal has no "done"; its stage is
35
- // its state, so the first column is just the name). Moderate data only (active
36
- // deals are dozens-to-hundreds); a 10k+ record set belongs in the paginated
37
- // register (tpl_dossier), not here.
38
- // ─────────────────────────────────────────────────────────────────────────────
39
-
40
- const MEMBERS = [
41
- { id: "m1", name: "Mai Anh" },
42
- { id: "m2", name: "Trọng" },
43
- { id: "m3", name: "Linh" },
44
- { id: "m4", name: "Sơn" },
45
- ];
46
- const MEMBER_OPTS = MEMBERS.map((m) => ({ value: m.id, label: m.name }));
47
- const memberName = (id: string | null) => MEMBERS.find((m) => m.id === id)?.name ?? "—";
48
-
49
- type Stage = "lead" | "qualified" | "proposal" | "won" | "lost";
50
- const STAGE: Record<Stage, OptionValue> = {
51
- lead: { key: "lead", label: "Lead", color: null },
52
- qualified: { key: "qualified", label: "Qualified", color: "blue" },
53
- proposal: { key: "proposal", label: "Proposal", color: "amber" },
54
- won: { key: "won", label: "Won", color: "emerald" },
55
- lost: { key: "lost", label: "Lost", color: "rose" },
56
- };
57
- const STAGE_ORDER = Object.keys(STAGE) as Stage[];
58
- const STAGE_OPTIONS = STAGE_ORDER.map((s) => ({ value: s, label: STAGE[s].label }));
59
- const renderStageBadge = (o: { value: string }) => <OptionBadge value={STAGE[o.value as Stage]} variant="dot" />;
60
-
61
- const money = (n: number | null) => (n == null ? "" : formatMoney(n));
62
-
63
- interface Deal {
64
- id: string;
65
- name: string;
66
- value: number | null;
67
- ownerId: string | null;
68
- close: string | null;
69
- stage: Stage;
70
- }
71
-
72
- const day = (n: number): string => {
73
- const d = new Date();
74
- d.setDate(d.getDate() + n);
75
- return toISODate(d);
76
- };
77
-
78
- const SEED: Deal[] = [
79
- { id: "d1", name: "VITTORIA — gift-box program", value: 42000, ownerId: "m1", close: day(6), stage: "proposal" },
80
- { id: "d2", name: "Northwind — carton refresh", value: 18500, ownerId: "m3", close: day(-2), stage: "qualified" },
81
- { id: "d3", name: "Crestline — Tet seasonal run", value: 64000, ownerId: "m1", close: day(20), stage: "lead" },
82
- { id: "d4", name: "Atlas — protective inserts", value: 9800, ownerId: "m2", close: day(2), stage: "qualified" },
83
- { id: "d5", name: "Summit — pallet labels", value: 5200, ownerId: "m4", close: null, stage: "lead" },
84
- { id: "d6", name: "Beacon — retail display units", value: 31000, ownerId: "m3", close: day(-9), stage: "won" },
85
- { id: "d7", name: "Harbor — export crates", value: 27500, ownerId: "m2", close: day(40), stage: "proposal" },
86
- { id: "d8", name: "Grace Co — sample kit", value: 3400, ownerId: null, close: null, stage: "lost" },
87
- ];
88
-
89
- type GroupBy = "stage" | "owner" | "close";
90
- const GROUP_OPTIONS: { value: GroupBy; label: string }[] = [
91
- { value: "stage", label: "Stage" },
92
- { value: "owner", label: "Owner" },
93
- { value: "close", label: "Close date" },
94
- ];
95
-
96
- function closeBucketKey(due: string | null): string {
97
- if (!due) return "none";
98
- const d = due.slice(0, 10);
99
- const today = day(0);
100
- if (d < today) return "overdue";
101
- if (d <= day(7)) return "week";
102
- if (d <= day(30)) return "month";
103
- return "later";
104
- }
105
- const CLOSE_LABELS: Record<string, string> = { overdue: "Overdue", week: "This week", month: "This month", later: "Later", none: "No date" };
106
- const CLOSE_ORDER = ["overdue", "week", "month", "later", "none"];
107
- const CLOSE_PRESET: Record<string, string | null> = { overdue: day(-1), week: day(3), month: day(20), later: day(60), none: null };
108
-
109
- function presetFor(groupBy: GroupBy | null, key: string): Partial<Deal> {
110
- if (groupBy === "stage") return { stage: key as Stage };
111
- if (groupBy === "owner") return { ownerId: key === "none" ? null : key };
112
- if (groupBy === "close") return { close: CLOSE_PRESET[key] ?? null };
113
- return {};
114
- }
115
-
116
- function sortValue(d: Deal, key: string): string | number {
117
- switch (key) {
118
- case "name": return d.name.toLowerCase();
119
- case "value": return d.value ?? -1;
120
- case "owner": return d.ownerId ? memberName(d.ownerId).toLowerCase() : "￿";
121
- case "close": return d.close ?? "￿";
122
- case "stage": return STAGE_ORDER.indexOf(d.stage);
123
- default: return "";
124
- }
125
- }
126
-
127
- // ── AI · draft deals from an inquiry — agent proposes, you keep/edit/drop ──────
128
- type Phase = "idle" | "reading" | "review" | "done";
129
- interface Proposal { id: string; name: string; value: number | null; stage: Stage; confidence: ConfidenceLevel; source: string }
130
- const DRAFT_STEPS: { label: string; detail: string }[] = [
131
- { label: "Split the inquiry into candidate deals", detail: "5 lines" },
132
- { label: "Drop context that isn't an opportunity", detail: "kept 3" },
133
- { label: "Estimate a value and stage for each", detail: "3 scored" },
134
- ];
135
- const DRAFTED: Proposal[] = [
136
- { id: "p1", name: "Meridian — Q3 carton program", value: 38000, stage: "qualified", confidence: "high", source: "“…Meridian wants pricing on ~40k cartons for Q3.”" },
137
- { id: "p2", name: "Oakline — sample run", value: 4200, stage: "lead", confidence: "medium", source: "“…Oakline asked for a small sample run first.”" },
138
- { id: "p3", name: "Pinecrest — retail display units", value: 21000, stage: "proposal", confidence: "low", source: "“…Pinecrest might need displays, budget unclear.”" },
139
- ];
140
-
141
- export function TplPipeline() {
142
- const [deals, setDeals] = useState<Deal[]>(SEED);
143
- const [query, setQuery] = useState("");
144
- const [groupBy, setGroupBy] = useState<GroupBy | null>("stage"); // null = ungrouped (cleared)
145
- const [ownerFilter, setOwnerFilter] = useState<string[]>([]);
146
- const [stageFilter, setStageFilter] = useState<string[]>([]);
147
- const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
148
- const toggleCollapse = (key: string) =>
149
- setCollapsed((c) => { const n = new Set(c); if (n.has(key)) n.delete(key); else n.add(key); return n; });
150
- const [sort, setSort] = useState<SortState | null>(null);
151
- const onSort = (key: string) => setSort((s) => cycleSort(s, key));
152
-
153
- const patch = (id: string, next: Partial<Deal>) =>
154
- setDeals((ds) => ds.map((d) => (d.id === id ? { ...d, ...next } : d)));
155
- const addDeal = (d: Omit<Deal, "id">) =>
156
- setDeals((ds) => [...ds, { id: `n${ds.length}-${d.name.length}`, ...d }]);
157
-
158
- // Draft-from-notes AI flow — agent proposes deals, you keep/edit/drop, commit adds them.
159
- const [draftOpen, setDraftOpen] = useState(false);
160
- const [phase, setPhase] = useState<Phase>("idle");
161
- const [revealed, setRevealed] = useState(0);
162
- const [proposals, setProposals] = useState<Proposal[]>([]);
163
- const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
164
- const [addedCount, setAddedCount] = useState(0);
165
-
166
- const startDraft = useCallback(() => { setRevealed(0); setPhase("reading"); }, []);
167
- const resetDraft = useCallback(() => { setProposals([]); setDecisions({}); setRevealed(0); setPhase("idle"); }, []);
168
- const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
169
- const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
170
- setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
171
- const decide = useCallback((id: string, status: ChangeReviewItemStatus | null) =>
172
- setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
173
- const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
174
- const addAccepted = useCallback(() => {
175
- const accepted = proposals.filter((p) => decisions[p.id] === "accepted");
176
- setDeals((ds) => [...ds, ...accepted.map((p, i) => ({ id: `d-${p.id}-${i}`, name: p.name, value: p.value, ownerId: null, close: null, stage: p.stage }))]);
177
- setAddedCount(accepted.length);
178
- setPhase("done");
179
- }, [proposals, decisions]);
180
-
181
- useEffect(() => {
182
- if (phase !== "reading") return;
183
- if (revealed >= DRAFT_STEPS.length) {
184
- const t = setTimeout(() => { setProposals(DRAFTED); setPhase("review"); }, 850);
185
- return () => clearTimeout(t);
186
- }
187
- const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 280 : 680);
188
- return () => clearTimeout(t);
189
- }, [phase, revealed]);
190
-
191
- const runItems: AgentRunItem[] = [
192
- { type: "text", id: "intro", text: "I read the inquiry and pulled out the opportunities." },
193
- ...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i): AgentRunItem => ({
194
- type: "step", id: `s${i}`, label: s.label, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
195
- })),
196
- ];
197
- if (revealed >= DRAFT_STEPS.length) {
198
- runItems.push({ type: "text", id: "outro", text: `Drafted ${DRAFTED.length} deals — keep the ones worth pursuing, edit any before adding.` });
199
- }
200
-
201
- const propertyCols: DataGridColumn<Deal>[] = [
202
- { key: "value", label: "Value", width: 132, sortable: true, cell: (d) => <InlineNumberInput value={d.value} onSave={(v) => patch(d.id, { value: v })} format={money} placeholder="—" accessibilityLabel="Value" /> },
203
- { key: "owner", label: "Owner", width: 168, sortable: true, cell: (d) => <InlineMemberSelect members={MEMBERS} value={d.ownerId} onSave={(id) => patch(d.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Owner" /> },
204
- { key: "close", label: "Close", width: 150, sortable: true, cell: (d) => <InlineDatePicker value={d.close} onSave={(v) => patch(d.id, { close: v })} placeholder="No date" accessibilityLabel="Close date" /> },
205
- { key: "stage", label: "Stage", width: 140, sortable: true, cell: (d) => <InlineSelect value={d.stage} options={STAGE_OPTIONS} onSave={(s) => patch(d.id, { stage: s })} renderSelected={renderStageBadge} renderOptionContent={renderStageBadge} accessibilityLabel="Stage" /> },
206
- ];
207
- const visibleProps = propertyCols.filter((c) => c.key !== groupBy);
208
- const columns: DataGridColumn<Deal>[] = [
209
- { key: "name", label: "Deal", sortable: true, cell: (d) => <InlineTextInput value={d.name} onSave={(v) => patch(d.id, { name: v })} accessibilityLabel="Deal name" /> },
210
- ...visibleProps,
211
- ];
212
-
213
- const groups = useMemo<DataGridGroup<Deal>[]>(() => {
214
- const q = query.trim().toLowerCase();
215
- const pool = deals.filter((d) => {
216
- if (q && !d.name.toLowerCase().includes(q)) return false;
217
- if (ownerFilter.length > 0 && !(d.ownerId && ownerFilter.includes(d.ownerId))) return false;
218
- if (stageFilter.length > 0 && !stageFilter.includes(d.stage)) return false;
219
- return true;
220
- });
221
- let out: DataGridGroup<Deal>[];
222
- if (!groupBy) {
223
- out = [{ key: "all", items: pool }];
224
- } else if (groupBy === "stage") {
225
- out = STAGE_ORDER.map((s) => ({ key: s, header: <OptionBadge value={STAGE[s]} variant="dot" />, items: pool.filter((d) => d.stage === s) }));
226
- } else if (groupBy === "owner") {
227
- out = [
228
- ...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size={22} />, items: pool.filter((d) => d.ownerId === m.id) })),
229
- { key: "none", header: <Text size="sm" color="muted">Unassigned</Text>, items: pool.filter((d) => !d.ownerId) },
230
- ];
231
- } else {
232
- out = CLOSE_ORDER.map((k) => ({ key: k, header: <Text size="xs" weight="semibold" color="muted" style={styles.bucketHead}>{CLOSE_LABELS[k]}</Text>, items: pool.filter((d) => closeBucketKey(d.close) === k) }));
233
- }
234
- const sorted = out.map((s) => ({ ...s, items: sortBy(s.items, sort, sortValue) }));
235
- return groupBy === "stage" ? sorted : sorted.filter((s) => s.items.length > 0);
236
- }, [deals, query, groupBy, ownerFilter, stageFilter, sort]);
237
-
238
- return (
239
- <>
240
- <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 24, paddingBottom: 96 }}>
241
- <View style={{ maxWidth: 1000, width: "100%", alignSelf: "center", gap: 16 }}>
242
- <Text size="xl" weight="semibold">Pipeline</Text>
243
-
244
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
245
- <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, flex: 1 }}>
246
- <View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
247
- <SearchInput value={query} onChangeText={setQuery} placeholder="Search deals…" accessibilityLabel="Search deals" />
248
- </View>
249
- <FilterChip label="Group by" summary={groupBy ? GROUP_OPTIONS.find((g) => g.value === groupBy)?.label : undefined} onClear={() => setGroupBy(null)} clearLabel="Clear grouping">
250
- {({ close }) => <OptionList search={{ mode: "none" }} options={GROUP_OPTIONS} value={groupBy ?? undefined} onValueChange={(v) => v && setGroupBy(v)} onRequestClose={close} />}
251
- </FilterChip>
252
- <FilterChip
253
- label="Owner"
254
- summary={ownerFilter.length > 0 ? <View style={styles.summary}>{ownerFilter.slice(0, 4).map((id) => <Avatar key={id} name={memberName(id)} size={20} />)}</View> : undefined}
255
- onClear={() => setOwnerFilter([])}
256
- clearLabel="Clear owner filter"
257
- >
258
- <OptionList search={{ mode: "none" }} multi options={MEMBER_OPTS} value={ownerFilter} onValueChange={setOwnerFilter} renderOptionContent={(o) => <MemberChip name={o.label ?? memberName(o.value)} size={22} />} />
259
- </FilterChip>
260
- <FilterChip
261
- label="Stage"
262
- summary={stageFilter.length > 0 ? <View style={styles.summary}>{stageFilter.map((v) => <View key={v} style={[styles.summaryDot, { backgroundColor: solid(asColorName(STAGE[v as Stage]?.color)) }]} />)}</View> : undefined}
263
- onClear={() => setStageFilter([])}
264
- clearLabel="Clear stage filter"
265
- >
266
- <OptionList search={{ mode: "none" }} multi options={STAGE_OPTIONS} value={stageFilter} onValueChange={setStageFilter} renderOptionContent={renderStageBadge} />
267
- </FilterChip>
268
- </View>
269
- <Button title="Draft from notes" color="primary" onPress={() => setDraftOpen(true)} />
270
- </View>
271
-
272
- <DataGrid
273
- columns={columns}
274
- groups={groups}
275
- getRowKey={(d) => d.id}
276
- sort={sort}
277
- onSort={onSort}
278
- collapsed={collapsed}
279
- onToggleCollapse={toggleCollapse}
280
- renderGroupFooter={(g) => <DealAddRow preset={presetFor(groupBy, g.key)} columns={visibleProps} onAdd={addDeal} />}
281
- />
282
- </View>
283
- </ScrollView>
284
-
285
- {/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds deals). */}
286
- <Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
287
- <DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
288
- <DialogScrollArea>
289
- {phase === "idle" ? (
290
- <View style={{ gap: 12 }}>
291
- <Text size="sm" color="muted">Paste an inquiry, an email, or call notes. Lotics drafts deals from it — nothing is added until you review and keep them.</Text>
292
- <Composer onSend={startDraft} placeholder="Paste the inquiry…" autoFocus sendLabel="Draft deals" />
293
- </View>
294
- ) : null}
295
- {phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
296
- {phase === "review" ? (
297
- <ChangeReview
298
- title="Drafted deals"
299
- summary={`${proposals.length} from the inquiry — keep, edit, or drop each. A “Low” mark is worth a glance.`}
300
- items={proposals}
301
- getKey={(p) => p.id}
302
- statusOf={(p) => decisions[p.id] ?? "pending"}
303
- renderItem={(p) => <DealProposal proposal={p} onEdit={editProposal} />}
304
- renderSummary={(p, kept) => (
305
- <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.name}</Text>
306
- )}
307
- onAcceptItem={(i) => decide(proposals[i].id, "accepted")}
308
- onRejectItem={(i) => decide(proposals[i].id, "rejected")}
309
- onUndoItem={(i) => decide(proposals[i].id, null)}
310
- />
311
- ) : null}
312
- {phase === "done" ? (
313
- <CompletionState title={`${addedCount} ${addedCount === 1 ? "deal" : "deals"} added`} summary="They're in your pipeline now." />
314
- ) : null}
315
- </DialogScrollArea>
316
- {phase === "review" ? (
317
- <DialogFooter>
318
- <ChangeReviewActions items={proposals} statusOf={(p) => decisions[p.id] ?? "pending"} onAcceptItem={(i) => decide(proposals[i].id, "accepted")} onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "deal" : "deals"}`} />
319
- </DialogFooter>
320
- ) : phase === "done" ? (
321
- <DialogFooter>
322
- <Button title="Draft more" color="secondary" onPress={resetDraft} />
323
- <Button title="Done" color="primary" onPress={closeDraft} />
324
- </DialogFooter>
325
- ) : null}
326
- </Dialog>
327
- </>
328
- );
329
- }
330
-
331
- function DealProposal({ proposal, onEdit }: { proposal: Proposal; onEdit: (id: string, p: Partial<Proposal>) => void }) {
332
- return (
333
- <View style={{ gap: 10 }}>
334
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
335
- <View style={{ flex: 1 }}>
336
- <InlineTextInput value={proposal.name} onSave={(v) => onEdit(proposal.id, { name: v })} accessibilityLabel="Deal name" />
337
- </View>
338
- <View style={{ paddingTop: 10 }}><Confidence level={proposal.confidence} /></View>
339
- </View>
340
- <View style={{ flexDirection: "row", gap: 12 }}>
341
- <View style={{ width: 132 }}>
342
- <InlineNumberInput value={proposal.value} onSave={(v) => onEdit(proposal.id, { value: v })} format={money} placeholder="—" accessibilityLabel="Value" />
343
- </View>
344
- <View style={{ width: 140 }}>
345
- <InlineSelect value={proposal.stage} options={STAGE_OPTIONS} onSave={(s) => onEdit(proposal.id, { stage: s })} renderSelected={renderStageBadge} renderOptionContent={renderStageBadge} accessibilityLabel="Stage" />
346
- </View>
347
- </View>
348
- <View style={styles.source}><Text size="xs" color="muted">{proposal.source}</Text></View>
349
- </View>
350
- );
351
- }
352
-
353
- function DealAddRow({ preset, columns, onAdd }: { preset: Partial<Deal>; columns: DataGridColumn<Deal>[]; onAdd: (d: Omit<Deal, "id">) => void }) {
354
- const [name, setName] = useState("");
355
- const [value, setValue] = useState<number | null>(null);
356
- const [ownerId, setOwnerId] = useState<string | null>(null);
357
- const [close, setClose] = useState<string | null>(null);
358
- const [stage, setStage] = useState<Stage>("lead");
359
-
360
- const submit = () => {
361
- const n = name.trim();
362
- if (!n) return;
363
- onAdd({ name: n, value, ownerId, close, stage, ...preset });
364
- setName("");
365
- setValue(null);
366
- setOwnerId(null);
367
- setClose(null);
368
- setStage("lead");
369
- };
370
-
371
- const editor = (key: string) =>
372
- key === "value" ? <InlineNumberInput value={value} onSave={setValue} format={money} placeholder="—" accessibilityLabel="Value" />
373
- : key === "owner" ? <InlineMemberSelect members={MEMBERS} value={ownerId} onSave={setOwnerId} placeholder="Unassigned" accessibilityLabel="Owner" />
374
- : key === "close" ? <InlineDatePicker value={close} onSave={setClose} placeholder="No date" accessibilityLabel="Close date" />
375
- : <InlineSelect value={stage} options={STAGE_OPTIONS} onSave={setStage} renderSelected={renderStageBadge} renderOptionContent={renderStageBadge} accessibilityLabel="Stage" />;
376
-
377
- return (
378
- <View style={[gridRowStyle, styles.add]}>
379
- <View style={styles.addField}>
380
- <View style={{ flex: 1 }}>
381
- <TextInputField value={name} onChangeText={setName} onSubmitEditing={submit} placeholder="Add a deal…" style={styles.input} accessibilityLabel="Add a deal" />
382
- </View>
383
- {name.trim() ? <Button title="Save" color="primary" onPress={submit} /> : null}
384
- </View>
385
- {columns.map((c) => (
386
- <View key={c.key} style={{ width: c.width }}>{editor(c.key)}</View>
387
- ))}
388
- </View>
389
- );
390
- }
391
-
392
- const styles = StyleSheet.create({
393
- bucketHead: { letterSpacing: 0.3, textTransform: "uppercase" },
394
- add: { opacity: 0.9 },
395
- input: { borderWidth: 0, backgroundColor: "transparent" },
396
- addField: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingRight: 4 },
397
- summary: { flexDirection: "row", alignItems: "center", gap: 4 },
398
- summaryDot: { width: 8, height: 8, borderRadius: 999 },
399
- source: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
400
- });