@lotics/ui 5.8.0 → 5.10.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 (104) hide show
  1. package/AGENTS.md +184 -56
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_dossier.tsx +5 -4
  5. package/examples/tpl_order.tsx +1 -1
  6. package/examples/tpl_pipeline.tsx +400 -0
  7. package/examples/tpl_ratedesk.tsx +1 -1
  8. package/examples/tpl_record.tsx +6 -5
  9. package/examples/tpl_shifts.tsx +7 -5
  10. package/examples/tpl_task_board.tsx +473 -0
  11. package/examples/tpl_tasks.tsx +440 -0
  12. package/package.json +19 -6
  13. package/src/accordion.tsx +1 -1
  14. package/src/agent_progress.tsx +1 -1
  15. package/src/agent_run.tsx +1 -1
  16. package/src/breakdown.tsx +2 -2
  17. package/src/button.tsx +12 -3
  18. package/src/calendar/calendar_view.tsx +10 -10
  19. package/src/calendar/month_view.tsx +30 -11
  20. package/src/calendar/time_grid_view.tsx +25 -7
  21. package/src/card_select_item.tsx +11 -13
  22. package/src/check_circle.tsx +88 -0
  23. package/src/checkbox_input.tsx +5 -1
  24. package/src/chip.tsx +1 -1
  25. package/src/chip_group.tsx +1 -1
  26. package/src/column_filter.tsx +3 -2
  27. package/src/combobox.tsx +84 -219
  28. package/src/confidence.tsx +14 -3
  29. package/src/control_surface.ts +22 -7
  30. package/src/data_grid.tsx +159 -0
  31. package/src/date_calendar.tsx +46 -23
  32. package/src/date_field.tsx +10 -2
  33. package/src/date_filter.tsx +5 -9
  34. package/src/date_picker.tsx +78 -26
  35. package/src/date_picker_value.ts +11 -0
  36. package/src/date_range_filter_field.tsx +6 -4
  37. package/src/drawer.tsx +1 -1
  38. package/src/file_dropzone.tsx +5 -0
  39. package/src/file_gallery_modal.tsx +227 -139
  40. package/src/file_grid.tsx +184 -0
  41. package/src/file_preview.web.tsx +349 -12
  42. package/src/file_preview_types.ts +49 -0
  43. package/src/file_row.tsx +118 -0
  44. package/src/file_rows.tsx +109 -0
  45. package/src/file_thumbnail.tsx +132 -81
  46. package/src/file_thumbnail_grid.tsx +135 -55
  47. package/src/files_editor.tsx +232 -0
  48. package/src/filter_chip.tsx +25 -12
  49. package/src/focus_ring_pressable.tsx +34 -0
  50. package/src/form_picker.tsx +3 -3
  51. package/src/gantt/gantt_view.tsx +25 -7
  52. package/src/heatmap.tsx +5 -1
  53. package/src/icon_button.tsx +14 -3
  54. package/src/image_gallery.tsx +8 -14
  55. package/src/index.css +8 -12
  56. package/src/inline_date_picker.tsx +11 -11
  57. package/src/inline_edit.tsx +22 -6
  58. package/src/inline_member_select.tsx +1 -1
  59. package/src/inline_select.tsx +9 -8
  60. package/src/inline_text_input.tsx +4 -1
  61. package/src/link.tsx +11 -1
  62. package/src/link_button.tsx +1 -1
  63. package/src/list_item.tsx +2 -2
  64. package/src/member_select.tsx +9 -4
  65. package/src/menu_button.tsx +2 -2
  66. package/src/menu_list_item.tsx +2 -2
  67. package/src/number_input.tsx +11 -4
  68. package/src/option_list.tsx +211 -0
  69. package/src/pdfjs_worker.d.ts +5 -0
  70. package/src/peek.tsx +1 -1
  71. package/src/picker.tsx +24 -213
  72. package/src/pressable_highlight.tsx +42 -23
  73. package/src/radio_picker.tsx +2 -2
  74. package/src/range_slider.tsx +35 -7
  75. package/src/react_native.d.ts +3 -0
  76. package/src/scroll_to_bottom.tsx +1 -1
  77. package/src/section.tsx +7 -2
  78. package/src/segmented_control.tsx +2 -2
  79. package/src/select.tsx +299 -0
  80. package/src/sort_header.tsx +1 -1
  81. package/src/sources.tsx +1 -1
  82. package/src/spreadsheet_view.tsx +43 -17
  83. package/src/status_grid.tsx +4 -3
  84. package/src/stepper.tsx +1 -1
  85. package/src/switch.tsx +5 -1
  86. package/src/switch_button.tsx +1 -1
  87. package/src/switcher.tsx +1 -1
  88. package/src/table.tsx +5 -1
  89. package/src/tabs.tsx +2 -2
  90. package/src/text_input_field.tsx +15 -4
  91. package/src/time_picker.tsx +11 -4
  92. package/src/timeline.tsx +1 -1
  93. package/src/uploading_thumbnail.tsx +212 -0
  94. package/src/use_focus_ring.ts +80 -0
  95. package/src/use_hover.ts +26 -0
  96. package/src/use_list_keyboard_nav.test.ts +71 -0
  97. package/src/use_list_keyboard_nav.ts +52 -5
  98. package/src/use_option_list.test.ts +193 -0
  99. package/src/use_option_list.ts +354 -0
  100. package/src/command_menu.tsx +0 -205
  101. package/src/hover_action.tsx +0 -54
  102. package/src/picker_menu.tsx +0 -355
  103. package/src/tag_input.tsx +0 -203
  104. package/src/time_field.tsx +0 -297
@@ -0,0 +1,400 @@
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
+ });
@@ -292,7 +292,7 @@ export function TplRatedesk() {
292
292
  ) : null}
293
293
  </Dialog>
294
294
 
295
- <FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} captionHint="ESC to close · ←/→ to move" />
295
+ <FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} />
296
296
  </>
297
297
  );
298
298
  }
@@ -13,7 +13,8 @@ import { InlineNumberInput } from "@lotics/ui/inline_number_input";
13
13
  import { InlineSelect } from "@lotics/ui/inline_select";
14
14
  import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
15
15
  import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
16
- import { TagInput, type TagOption } from "@lotics/ui/tag_input";
16
+ import { Select } from "@lotics/ui/select";
17
+ import { Chip } from "@lotics/ui/chip";
17
18
  import { FileDropzone } from "@lotics/ui/file_dropzone";
18
19
  import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
19
20
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
@@ -47,7 +48,7 @@ const HEALTH_DOT: Record<string, string> = {
47
48
 
48
49
  // Tags — an inline MULTI-select: chips render in the field, search to add, ✕ to
49
50
  // remove, free-entry to coin a new tag (`allowCustom`).
50
- const TAG_OPTIONS: TagOption[] = [
51
+ const TAG_OPTIONS = [
51
52
  { value: "vip", label: "VIP" },
52
53
  { value: "priority", label: "Priority support" },
53
54
  { value: "renewal_risk", label: "Renewal risk" },
@@ -177,7 +178,7 @@ export function TplRecord() {
177
178
  const [review, setReview] = useState("2026-04-10T14:30");
178
179
  const [opens, setOpens] = useState("09:00");
179
180
  const [stage, setStage] = useState<Stage>("lead");
180
- const [tags, setTags] = useState<TagOption[]>([TAG_OPTIONS[0], TAG_OPTIONS[3]]);
181
+ const [tags, setTags] = useState<string[]>([TAG_OPTIONS[0].value, TAG_OPTIONS[3].value]);
181
182
  const [docs, setDocs] = useState<DisplayFile[]>(SEED_DOCS);
182
183
  const [activeDoc, setActiveDoc] = useState<number | null>(null);
183
184
 
@@ -240,7 +241,7 @@ export function TplRecord() {
240
241
  />
241
242
  </Field>
242
243
  <Field label="Tags">
243
- <TagInput value={tags} options={TAG_OPTIONS} onChange={setTags} allowCreate placeholder="Add tags…" accessibilityLabel="Tags" />
244
+ <Select multi searchable allowCustom value={tags} onValueChange={setTags} options={TAG_OPTIONS} renderSelected={(item, { remove }) => <Chip onDismiss={remove}><Text size="sm">{item.label}</Text></Chip>} placeholder="Add tags…" accessibilityLabel="Tags" />
244
245
  </Field>
245
246
  </Group>
246
247
 
@@ -295,7 +296,7 @@ export function TplRecord() {
295
296
  </Card>
296
297
  </View>
297
298
 
298
- <FileGalleryModal files={docs} activeIndex={activeDoc} onIndexChange={setActiveDoc} captionHint="ESC to close · ←/→ to move" />
299
+ <FileGalleryModal files={docs} activeIndex={activeDoc} onIndexChange={setActiveDoc} />
299
300
  </ScrollView>
300
301
  );
301
302
  }
@@ -14,6 +14,7 @@ import { EmptyState } from "@lotics/ui/empty_state";
14
14
  import { KPIStrip } from "@lotics/ui/kpi_strip";
15
15
  import { MenuListItem } from "@lotics/ui/menu_list_item";
16
16
  import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
17
+ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
17
18
  import { PressableRow } from "@lotics/ui/pressable_row";
18
19
  import { ProgressBar } from "@lotics/ui/progress_bar";
19
20
  import { Screen } from "@lotics/ui/screen_router";
@@ -227,12 +228,13 @@ export function TplShifts() {
227
228
  const color = coverageColor(filled, req);
228
229
  const isOpen = openSlot === id;
229
230
  return (
230
- <Pressable
231
+ <PressableHighlight
231
232
  key={d.key}
233
+ focusRing
232
234
  accessibilityRole="button"
233
235
  accessibilityLabel={`${slotLabel(d.key, s.key)}: ${filled} of ${req} filled`}
234
236
  onPress={() => setOpenSlot(id)}
235
- style={{
237
+ style={(state) => ({
236
238
  flex: 1,
237
239
  minHeight: 44,
238
240
  borderRadius: 8,
@@ -240,11 +242,11 @@ export function TplShifts() {
240
242
  justifyContent: "center",
241
243
  backgroundColor: withAlpha(color, filled >= req ? 0.12 : 0.16),
242
244
  borderWidth: 2,
243
- borderColor: isOpen ? colors.zinc[900] : "transparent",
244
- }}
245
+ borderColor: isOpen ? colors.zinc[900] : state.hovered ? colors.zinc[400] : "transparent",
246
+ })}
245
247
  >
246
248
  <Text size="sm" weight="medium" tabular>{`${filled}/${req}`}</Text>
247
- </Pressable>
249
+ </PressableHighlight>
248
250
  );
249
251
  })}
250
252
  </View>