@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.
- package/AGENTS.md +75 -33
- package/examples/tpl_allocate.tsx +6 -6
- package/examples/tpl_assistant.tsx +1 -1
- package/examples/tpl_attendance.tsx +1 -1
- package/examples/tpl_billing.tsx +2 -2
- package/examples/tpl_briefing.tsx +1 -1
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_compare.tsx +1 -1
- package/examples/tpl_crosscheck.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_detail.tsx +1 -1
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_directory.tsx +5 -8
- package/examples/tpl_draft.tsx +2 -2
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_intake.tsx +1 -1
- package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
- package/examples/tpl_lookup.tsx +2 -2
- package/examples/tpl_match.tsx +2 -2
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pick.tsx +64 -68
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_quick.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +1 -1
- package/examples/tpl_record_plain.tsx +1 -1
- package/examples/tpl_report.tsx +1 -1
- package/examples/tpl_rollup.tsx +1 -1
- package/examples/tpl_settings.tsx +1 -1
- package/examples/tpl_shifts.tsx +1 -1
- package/examples/tpl_stock.tsx +1 -1
- package/examples/tpl_task_board.tsx +2 -2
- package/examples/tpl_tasks.tsx +1 -1
- package/examples/tpl_timeline.tsx +1 -1
- package/examples/tpl_tower.tsx +1 -1
- package/examples/tpl_triage.tsx +1 -1
- package/examples/tpl_wizard.tsx +1 -1
- package/package.json +3 -1
- package/src/card.tsx +6 -13
- package/src/combobox.tsx +28 -11
- package/src/data_grid.tsx +1 -1
- package/src/kpi_strip.tsx +5 -5
- package/src/pressable_row.tsx +22 -10
- package/src/sort_header.tsx +9 -4
- package/src/summary_line.tsx +76 -0
- package/src/table.tsx +27 -11
- package/src/use_selection.test.ts +66 -0
- package/src/use_selection.ts +75 -0
- package/examples/app_orders.tsx +0 -405
- package/examples/tpl_approvals.tsx +0 -375
- package/examples/tpl_batch.tsx +0 -234
- package/examples/tpl_callsheet.tsx +0 -481
- package/examples/tpl_convert.tsx +0 -478
- package/examples/tpl_crm_desk.tsx +0 -541
- package/examples/tpl_dispatch.tsx +0 -289
- package/examples/tpl_inventory.tsx +0 -299
- package/examples/tpl_pipeline.tsx +0 -400
- package/examples/tpl_reconcile.tsx +0 -275
- 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
|
-
});
|