@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.
- package/AGENTS.md +184 -56
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +6 -5
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +19 -6
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +227 -139
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +349 -12
- package/src/file_preview_types.ts +49 -0
- package/src/file_row.tsx +118 -0
- package/src/file_rows.tsx +109 -0
- package/src/file_thumbnail.tsx +132 -81
- package/src/file_thumbnail_grid.tsx +135 -55
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +14 -3
- package/src/image_gallery.tsx +8 -14
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/hover_action.tsx +0 -54
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- 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}
|
|
295
|
+
<FileGalleryModal files={files} activeIndex={activeFile} onIndexChange={setActiveFile} />
|
|
296
296
|
</>
|
|
297
297
|
);
|
|
298
298
|
}
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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<
|
|
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
|
-
<
|
|
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}
|
|
299
|
+
<FileGalleryModal files={docs} activeIndex={activeDoc} onIndexChange={setActiveDoc} />
|
|
299
300
|
</ScrollView>
|
|
300
301
|
);
|
|
301
302
|
}
|
package/examples/tpl_shifts.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
249
|
+
</PressableHighlight>
|
|
248
250
|
);
|
|
249
251
|
})}
|
|
250
252
|
</View>
|