@ikenga/pkg-sales 0.2.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/LICENSE +201 -0
- package/README.md +61 -0
- package/dist/app.js +161 -0
- package/dist/features/sales/sales-view.js +837 -0
- package/dist/index.html +52 -0
- package/dist/lib/app-kit-css.js +3 -0
- package/dist/lib/bridge.js +182 -0
- package/dist/lib/sales-css.js +4 -0
- package/dist/lib/tokens-css.js +3 -0
- package/dist/lib/ui.js +107 -0
- package/dist/sales.css +459 -0
- package/manifest.json +59 -0
- package/package.json +26 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
// Sales main view — Pipeline (list + kanban) / Forecast / Won.
|
|
2
|
+
//
|
|
3
|
+
// Composition follows plans/atelier-design-system/parts/screens/sales.md §§1–4:
|
|
4
|
+
// Views: ?view=0 Pipeline (list default + kanban toggle) | ?view=1 Forecast | ?view=2 Won
|
|
5
|
+
//
|
|
6
|
+
// Kit parts consumed:
|
|
7
|
+
// - .frame / .frame-head / .frame-body-flush (part 30 pkg-pane-frame)
|
|
8
|
+
// - .ip-split / .ip-split-list / .ip-split-divider / .ip-split-pane (part 25 inspector-detail)
|
|
9
|
+
// - .split-row / .split-row-* / .split-group / .split-detail / .split-detail-* (list-detail)
|
|
10
|
+
// - .dense-row.dense-row--pipeline / .dense-row-* (part 20 table-dense-row)
|
|
11
|
+
// - .kb-board / .kb-col / .kb-card / .kb-mini-avatar / .kb-add (part 28 kanban)
|
|
12
|
+
// - .nav-group[data-kind] / .nav-item / .nav-item.is-on / .nav-item.is-hot (part 22)
|
|
13
|
+
// - .seg.nav-view-seg / .seg button.is-on (part 14 segmented-tabs, list-kanban-switch)
|
|
14
|
+
// - .atelier-state.is-{loading,empty,error,streaming} / .atelier-spin (part 26 feedback-state)
|
|
15
|
+
// - .stage-chip / .next-chip / .ux-dot / .badge / .tag / .chip (part 11 badge-tag-chip)
|
|
16
|
+
// - .btn / .btn-icon / .btn-sm / .btn.affirmative (part 10 buttons)
|
|
17
|
+
//
|
|
18
|
+
// Domain-local (sales.css .sl-*):
|
|
19
|
+
// - .sl-forecast-* / .sl-kpi-* / .sl-funnel-* / .sl-month-* (Forecast view)
|
|
20
|
+
// - .sl-won-* / .sl-won-badge / .sl-won-amt (Won view)
|
|
21
|
+
// - .ux-dot.ux-{confirm,silent,approve} / .next-chip / .stage-chip / .split-* overrides
|
|
22
|
+
//
|
|
23
|
+
// Data: host.dbQuery + host.dbExec via AppBridge. TanStack Query for caching.
|
|
24
|
+
// Migration: 0043_sales_domain.sql — app-layer columns (title, owner, next_action,
|
|
25
|
+
// next_action_mode, win_probability) on sales_deals. Stage enum: lead → qualified →
|
|
26
|
+
// proposal → negotiation → closing → won | lost.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
html, cn, Icon,
|
|
30
|
+
useState, useEffect, useMemo, useCallback,
|
|
31
|
+
useQuery, useMutation, useQueryClient,
|
|
32
|
+
} from '../../lib/ui.js';
|
|
33
|
+
import { hostDbQuery, hostDbExec, setMenu, isStandalone } from '../../lib/bridge.js';
|
|
34
|
+
|
|
35
|
+
// ─── Stage enum ───────────────────────────────────────────────────────────────
|
|
36
|
+
// Per the R-04 Pipeline-stages convention (06-skill-action-contract.md §Pipeline-stages).
|
|
37
|
+
// Defined + documented here as the owning pkg (the domain WP defines the enum in README).
|
|
38
|
+
// Terminals: won | lost. Won view = sales_deals WHERE stage = 'won'.
|
|
39
|
+
|
|
40
|
+
const STAGES = ['lead', 'qualified', 'proposal', 'negotiation', 'closing'];
|
|
41
|
+
const TERMINAL_STAGES = ['won', 'lost'];
|
|
42
|
+
|
|
43
|
+
/** Tolerant grouping (founder call 2026-06-06, WP-18b live-verify follow-up):
|
|
44
|
+
* rows with a non-enum stage render as their own visible group/column (raw
|
|
45
|
+
* value as label) instead of silently vanishing from the stage-grouped views.
|
|
46
|
+
* 0045_sales_stage_backfill maps known legacy values; this guards the rest. */
|
|
47
|
+
function stagesWithExtras(grouped) {
|
|
48
|
+
const extras = Object.keys(grouped)
|
|
49
|
+
.filter((s) => !STAGES.includes(s) && grouped[s].length > 0)
|
|
50
|
+
.sort();
|
|
51
|
+
return [...STAGES, ...extras];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const STAGE_LABEL = {
|
|
55
|
+
lead: 'Lead',
|
|
56
|
+
qualified: 'Qualified',
|
|
57
|
+
proposal: 'Proposal',
|
|
58
|
+
negotiation: 'Negotiation',
|
|
59
|
+
closing: 'Closing',
|
|
60
|
+
won: 'Won',
|
|
61
|
+
lost: 'Lost',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ─── Fixture data (canonical — mirrors sales.md §1) ──────────────────────────
|
|
65
|
+
// Used as app-layer fallback until 0043 migration applies and real columns exist.
|
|
66
|
+
// Mock contract 1: app-layer fallbacks until migration lands. Must flip to real
|
|
67
|
+
// columns before sign-off (DoD: "MOCK: contract 1 … must flip to real columns").
|
|
68
|
+
|
|
69
|
+
const OPEN_DEALS_FIXTURE = [
|
|
70
|
+
{ id: 'D-01', title: 'Catalog migration', company: 'Dapper Music', stage: 'lead', value: 30000, currency: 'USD', owner: 'nedjamez', next_action: 'Discovery call', next_action_mode: 'confirm', win_probability: 0.15, assigned_to: 'nedjamez', age_days: 12 },
|
|
71
|
+
{ id: 'D-02', title: 'Self-serve → team', company: 'Tay Iwar', stage: 'lead', value: 6000, currency: 'USD', owner: 'nedjamez', next_action: 'Qualify fit', next_action_mode: 'silent', win_probability: 0.10, assigned_to: 'nedjamez', age_days: 5 },
|
|
72
|
+
{ id: 'D-03', title: 'Analytics pilot', company: 'Aristokrat Records', stage: 'qualified', value: 18000, currency: 'USD', owner: 'sales-agent',next_action: 'Schedule demo', next_action_mode: 'silent', win_probability: 0.40, assigned_to: 'sales-agent',age_days: 8 },
|
|
73
|
+
{ id: 'D-04', title: 'Pilot → migration', company: 'Baseline Music', stage: 'qualified', value: 12000, currency: 'USD', owner: 'sales-agent',next_action: 'Send pilot scope', next_action_mode: 'confirm', win_probability: 0.35, assigned_to: 'sales-agent',age_days: 14 },
|
|
74
|
+
{ id: 'D-05', title: 'Catalog onboarding', company: 'Chocolate City', stage: 'proposal', value: 48000, currency: 'USD', owner: 'sales-agent',next_action: 'Send MSA for signature', next_action_mode: 'approve', win_probability: 0.60, assigned_to: 'sales-agent',age_days: 21 },
|
|
75
|
+
{ id: 'D-06', title: 'Enterprise — multi-label', company: 'Mavin Records', stage: 'negotiation', value: 72000, currency: 'USD', owner: 'nedjamez', next_action: 'Pricing call Tue 11:00', next_action_mode: 'confirm', win_probability: 0.70, assigned_to: 'nedjamez', age_days: 19 },
|
|
76
|
+
{ id: 'D-07', title: 'Reseller agreement', company: 'Empire Distribution', stage: 'negotiation', value: 200000, currency: 'USD', owner: 'nedjamez', next_action: 'Legal review of reseller terms', next_action_mode: 'approve', win_probability: 0.65, assigned_to: 'nedjamez', age_days: 34 },
|
|
77
|
+
{ id: 'D-08', title: 'Countersign — enterprise', company: 'Native Records', stage: 'closing', value: 120000, currency: 'USD', owner: 'sales-agent',next_action: 'Countersign + provision tenant', next_action_mode: 'approve', win_probability: 0.85, assigned_to: 'sales-agent',age_days: 41 },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const WON_DEALS_FIXTURE = [
|
|
81
|
+
{ id: 'W-01', title: 'Mavin catalog sync', company: 'Mavin Records', source: 'referral', owner: 'nedjamez', closed: 'Apr 28', value: 54000 },
|
|
82
|
+
{ id: 'W-02', title: 'Tooth & Nail migration', company: 'Tooth & Nail', source: 'inbound', owner: 'sales-agent', closed: 'Apr 21', value: 36000 },
|
|
83
|
+
{ id: 'W-03', title: 'Indie bundle', company: 'Indie Co', source: 'self-serve', owner: 'nedjamez', closed: 'Apr 14', value: 18000 },
|
|
84
|
+
{ id: 'W-04', title: 'Analytics annual', company: 'Aristokrat Records', source: 'outbound', owner: 'sales-agent', closed: 'Apr 9', value: 42000 },
|
|
85
|
+
{ id: 'W-05', title: 'Reseller — West Africa', company: 'Synco Distribution', source: 'partner', owner: 'nedjamez', closed: 'Mar 30', value: 96000 },
|
|
86
|
+
{ id: 'W-06', title: 'Pilot conversion', company: 'Baseline Music', source: 'pilot', owner: 'sales-agent', closed: 'Mar 22', value: 66000 },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// ─── Query keys ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const QK = {
|
|
92
|
+
openDeals: ['sales', 'deals', 'open'],
|
|
93
|
+
wonDeals: ['sales', 'deals', 'won'],
|
|
94
|
+
forecasts: ['sales', 'forecasts'],
|
|
95
|
+
activities: (dealId) => ['sales', 'activities', dealId],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ─── Data fetchers ────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async function fetchOpenDeals() {
|
|
101
|
+
if (isStandalone()) return OPEN_DEALS_FIXTURE;
|
|
102
|
+
try {
|
|
103
|
+
// Try real columns from 0043 migration first (title, owner, next_action, next_action_mode, win_probability).
|
|
104
|
+
const rows = await hostDbQuery(
|
|
105
|
+
`SELECT id, company, contact_name, contact_email, stage, value, currency, score,
|
|
106
|
+
last_contact, assigned_to, notes, source, loss_reason, description,
|
|
107
|
+
title, owner, next_action, next_action_mode, win_probability,
|
|
108
|
+
days_in_stage, stage_entered_date, expected_close_date
|
|
109
|
+
FROM sales_deals
|
|
110
|
+
WHERE stage NOT IN ('won', 'lost')
|
|
111
|
+
ORDER BY last_contact DESC`
|
|
112
|
+
);
|
|
113
|
+
if (!rows.length) return OPEN_DEALS_FIXTURE;
|
|
114
|
+
// Merge fixture app-layer fields as fallback when columns are NULL (pre-migration).
|
|
115
|
+
return rows.map((r, i) => {
|
|
116
|
+
const fix = OPEN_DEALS_FIXTURE[i % OPEN_DEALS_FIXTURE.length];
|
|
117
|
+
return {
|
|
118
|
+
...r,
|
|
119
|
+
title: r.title ?? r.company,
|
|
120
|
+
owner: r.owner ?? r.assigned_to ?? fix.owner,
|
|
121
|
+
next_action: r.next_action ?? fix.next_action,
|
|
122
|
+
next_action_mode: r.next_action_mode ?? fix.next_action_mode,
|
|
123
|
+
win_probability: r.win_probability ?? fix.win_probability,
|
|
124
|
+
age_days: r.days_in_stage ?? fix.age_days ?? 0,
|
|
125
|
+
value: typeof r.value === 'string' ? parseFloat(r.value) || 0 : (r.value ?? 0),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// Bridge unavailable or table missing — return fixture.
|
|
130
|
+
return OPEN_DEALS_FIXTURE;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchWonDeals() {
|
|
135
|
+
if (isStandalone()) return WON_DEALS_FIXTURE;
|
|
136
|
+
try {
|
|
137
|
+
const rows = await hostDbQuery(
|
|
138
|
+
`SELECT id, company, contact_name, stage, value, currency, source, assigned_to,
|
|
139
|
+
last_contact, owner, title
|
|
140
|
+
FROM sales_deals
|
|
141
|
+
WHERE stage = 'won'
|
|
142
|
+
ORDER BY last_contact DESC`
|
|
143
|
+
);
|
|
144
|
+
if (!rows.length) return WON_DEALS_FIXTURE;
|
|
145
|
+
return rows.map((r) => ({
|
|
146
|
+
...r,
|
|
147
|
+
title: r.title ?? r.company,
|
|
148
|
+
owner: r.owner ?? r.assigned_to ?? 'nedjamez',
|
|
149
|
+
closed: r.last_contact ? r.last_contact.substring(0, 10) : '—',
|
|
150
|
+
value: typeof r.value === 'string' ? parseFloat(r.value) || 0 : (r.value ?? 0),
|
|
151
|
+
}));
|
|
152
|
+
} catch {
|
|
153
|
+
return WON_DEALS_FIXTURE;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchActivities(dealId) {
|
|
158
|
+
if (isStandalone() || !dealId) return [];
|
|
159
|
+
try {
|
|
160
|
+
return await hostDbQuery(
|
|
161
|
+
`SELECT id, activity_type, title, description, performed_by, activity_date
|
|
162
|
+
FROM sales_activities
|
|
163
|
+
WHERE deal_id = ?
|
|
164
|
+
ORDER BY activity_date DESC
|
|
165
|
+
LIMIT 3`,
|
|
166
|
+
[dealId]
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function fmtCurrency(v) {
|
|
176
|
+
if (!v && v !== 0) return '—';
|
|
177
|
+
const n = typeof v === 'string' ? parseFloat(v) : v;
|
|
178
|
+
if (isNaN(n)) return '—';
|
|
179
|
+
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
|
|
180
|
+
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`;
|
|
181
|
+
return `$${n.toFixed(0)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function fmtPct(v) {
|
|
185
|
+
if (v == null) return '—';
|
|
186
|
+
return `${Math.round(v * 100)}%`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Menu builder ─────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build the sidebar menu items for setMenu.
|
|
193
|
+
* activeView: 0 = Pipeline | 1 = Forecast | 2 = Won
|
|
194
|
+
* pipeMode: 'list' | 'kanban' (only relevant when activeView === 0)
|
|
195
|
+
* deals: the open deals array (for counts)
|
|
196
|
+
*/
|
|
197
|
+
function buildSalesMenu(activeView, pipeMode, deals) {
|
|
198
|
+
const openCount = deals.length;
|
|
199
|
+
const myDeals = deals.filter((d) => d.owner === 'nedjamez' || d.assigned_to === 'nedjamez');
|
|
200
|
+
const closingSoon = deals.filter((d) => d.next_action_mode === 'approve');
|
|
201
|
+
const agentRun = deals.filter((d) => (d.owner === 'sales-agent' || d.assigned_to === 'sales-agent'));
|
|
202
|
+
|
|
203
|
+
const viewItems = [
|
|
204
|
+
{
|
|
205
|
+
id: 'v:pipeline',
|
|
206
|
+
label: 'Pipeline',
|
|
207
|
+
icon: 'trending-up',
|
|
208
|
+
section: 'View',
|
|
209
|
+
active: activeView === 0,
|
|
210
|
+
badge: openCount > 0 ? openCount : undefined,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'v:forecast',
|
|
214
|
+
label: 'Forecast',
|
|
215
|
+
icon: 'dollar-sign',
|
|
216
|
+
section: 'View',
|
|
217
|
+
active: activeView === 1,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'v:won',
|
|
221
|
+
label: 'Won',
|
|
222
|
+
icon: 'check-circle',
|
|
223
|
+
section: 'View',
|
|
224
|
+
active: activeView === 2,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
// Seg control injected as a synthetic menu item when activeView === 0.
|
|
229
|
+
// The shell side-menu renders items with kind="seg" as an inline segmented control.
|
|
230
|
+
// We encode it as a special item the shell knows about (list-kanban-switch part §2).
|
|
231
|
+
const segItem = activeView === 0
|
|
232
|
+
? [{
|
|
233
|
+
id: 'seg:list-kanban',
|
|
234
|
+
kind: 'seg',
|
|
235
|
+
section: 'View',
|
|
236
|
+
options: [
|
|
237
|
+
// Option ids ARE the activeFeature values the shell publishes back
|
|
238
|
+
// (PkgMenuItem kind:'seg' contract — pkg-menu-store.ts).
|
|
239
|
+
{ id: 'seg:list', label: 'List', active: pipeMode === 'list' },
|
|
240
|
+
{ id: 'seg:kanban', label: 'Kanban', active: pipeMode === 'kanban' },
|
|
241
|
+
],
|
|
242
|
+
}]
|
|
243
|
+
: [];
|
|
244
|
+
|
|
245
|
+
const filterItems = [
|
|
246
|
+
{
|
|
247
|
+
id: 'f:open-pipeline',
|
|
248
|
+
label: 'Open pipeline',
|
|
249
|
+
icon: 'layers',
|
|
250
|
+
section: 'Filters',
|
|
251
|
+
active: true,
|
|
252
|
+
badge: openCount,
|
|
253
|
+
disabled: activeView !== 0,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: 'f:my-deals',
|
|
257
|
+
label: 'My deals',
|
|
258
|
+
icon: 'user',
|
|
259
|
+
section: 'Filters',
|
|
260
|
+
active: false,
|
|
261
|
+
badge: myDeals.length || undefined,
|
|
262
|
+
disabled: activeView !== 0,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: 'f:closing-soon',
|
|
266
|
+
label: 'Closing soon',
|
|
267
|
+
icon: 'zap',
|
|
268
|
+
section: 'Filters',
|
|
269
|
+
active: false,
|
|
270
|
+
hot: closingSoon.length > 0,
|
|
271
|
+
badge: closingSoon.length > 0 ? closingSoon.length : undefined,
|
|
272
|
+
disabled: activeView !== 0,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'f:agent-run',
|
|
276
|
+
label: 'Agent-run',
|
|
277
|
+
icon: 'cpu',
|
|
278
|
+
section: 'Filters',
|
|
279
|
+
active: false,
|
|
280
|
+
badge: agentRun.length || undefined,
|
|
281
|
+
disabled: activeView !== 0,
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const stageItems = STAGES.map((s) => ({
|
|
286
|
+
id: `f:stage:${s}`,
|
|
287
|
+
label: STAGE_LABEL[s],
|
|
288
|
+
section: 'By stage',
|
|
289
|
+
active: false,
|
|
290
|
+
badge: deals.filter((d) => d.stage === s).length || undefined,
|
|
291
|
+
disabled: activeView !== 0,
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
return [...viewItems, ...segItem, ...filterItems, ...stageItems];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── Sub-components ────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/** Single deal row in list mode */
|
|
300
|
+
function DealRow({ deal, isSelected, onClick }) {
|
|
301
|
+
const isUrgent = (deal.age_days ?? 0) > 30;
|
|
302
|
+
return html`
|
|
303
|
+
<div
|
|
304
|
+
class=${cn('split-row dense-row dense-row--pipeline', isSelected && 'is-selected')}
|
|
305
|
+
role="row"
|
|
306
|
+
tabIndex=${0}
|
|
307
|
+
onClick=${onClick}
|
|
308
|
+
onKeyDown=${(e) => e.key === 'Enter' && onClick()}
|
|
309
|
+
>
|
|
310
|
+
<span class="split-row-accent" aria-hidden="true"></span>
|
|
311
|
+
<div class="split-row-body dense-row-body">
|
|
312
|
+
<div class="split-row-title dense-row-title">${deal.title ?? deal.company}</div>
|
|
313
|
+
<div class="split-row-sub dense-row-sub">
|
|
314
|
+
${deal.company}
|
|
315
|
+
${deal.owner ? html` · <span>${deal.owner === 'sales-agent' ? '⚡ sales-agent' : deal.owner}</span>` : null}
|
|
316
|
+
</div>
|
|
317
|
+
${deal.next_action ? html`
|
|
318
|
+
<div class="split-row-sub">
|
|
319
|
+
<span class="next-chip">
|
|
320
|
+
<span class=${cn('ux-dot', `ux-${deal.next_action_mode ?? 'silent'}`)}></span>
|
|
321
|
+
${deal.next_action}
|
|
322
|
+
</span>
|
|
323
|
+
</div>
|
|
324
|
+
` : null}
|
|
325
|
+
</div>
|
|
326
|
+
<div class="split-row-right dense-row-right">
|
|
327
|
+
<span class="split-row-amt dense-row-amt">${fmtCurrency(deal.value)}</span>
|
|
328
|
+
<span class=${cn('split-row-when dense-row-due', isUrgent && 'is-urgent')}>
|
|
329
|
+
${deal.age_days != null ? `${deal.age_days}d` : ''}
|
|
330
|
+
</span>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Deal detail pane */
|
|
337
|
+
function DealDetail({ deal, activities }) {
|
|
338
|
+
if (!deal) {
|
|
339
|
+
return html`<div class="ip-split-pane split-detail" style=${{ display:'flex', alignItems:'center', justifyContent:'center', color:'var(--fg-muted)', fontSize:'0.85rem' }}>
|
|
340
|
+
Select a deal
|
|
341
|
+
</div>`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const hasApprove = deal.next_action_mode === 'approve';
|
|
345
|
+
const hasConfirm = deal.next_action_mode === 'confirm';
|
|
346
|
+
const showButton = hasApprove || hasConfirm;
|
|
347
|
+
|
|
348
|
+
return html`
|
|
349
|
+
<div class="ip-split-pane split-detail" style=${{ overflowY:'auto' }}>
|
|
350
|
+
<div class="split-detail-wrap">
|
|
351
|
+
<div class="split-detail-eyebrow">
|
|
352
|
+
<span class="stage-chip">${STAGE_LABEL[deal.stage] ?? deal.stage}</span>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="split-detail-title">${deal.title ?? deal.company}</div>
|
|
355
|
+
<div class="split-detail-sub">${deal.company}${deal.owner ? ` · ${deal.owner}` : ''}</div>
|
|
356
|
+
|
|
357
|
+
<div class="split-facts">
|
|
358
|
+
<div><div class="split-fact-k">Value</div><div class="split-fact-v">${fmtCurrency(deal.value)}</div></div>
|
|
359
|
+
<div><div class="split-fact-k">Stage</div><div class="split-fact-v">${STAGE_LABEL[deal.stage] ?? deal.stage}</div></div>
|
|
360
|
+
<div><div class="split-fact-k">Win prob.</div><div class="split-fact-v">${fmtPct(deal.win_probability)}</div></div>
|
|
361
|
+
<div><div class="split-fact-k">Age</div><div class="split-fact-v">${deal.age_days != null ? `${deal.age_days}d` : '—'}</div></div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
${deal.next_action ? html`
|
|
365
|
+
<div class="split-next">
|
|
366
|
+
<div class="split-next-head">
|
|
367
|
+
<span class=${cn('ux-dot', `ux-${deal.next_action_mode ?? 'silent'}`)}></span>
|
|
368
|
+
Next action
|
|
369
|
+
</div>
|
|
370
|
+
<div class="split-next-body">
|
|
371
|
+
${deal.next_action}
|
|
372
|
+
${showButton ? html`
|
|
373
|
+
<div style=${{ marginTop: '10px' }}>
|
|
374
|
+
<button class=${cn('btn', hasApprove ? 'affirmative' : '')} type="button">
|
|
375
|
+
${hasApprove ? 'Approve & run' : 'Confirm & run'}
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
` : null}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
` : null}
|
|
382
|
+
|
|
383
|
+
${activities && activities.length > 0 ? html`
|
|
384
|
+
<div class="split-detail-eyebrow" style=${{ marginTop:'12px' }}>Activity</div>
|
|
385
|
+
<div class="split-timeline" role="list">
|
|
386
|
+
${activities.map((a) => html`
|
|
387
|
+
<div class="split-tl-row" role="listitem" key=${a.id}>
|
|
388
|
+
<span class="split-tl-dot" aria-hidden="true"></span>
|
|
389
|
+
<div class="split-tl-body">
|
|
390
|
+
<div class="split-tl-title">${a.title ?? a.activity_type}</div>
|
|
391
|
+
<div class="split-tl-when">${a.activity_date ? a.activity_date.substring(0,10) : ''} · ${a.performed_by ?? ''}</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
`)}
|
|
395
|
+
</div>
|
|
396
|
+
` : null}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Pipeline list mode */
|
|
403
|
+
function PipelineList({ deals, selectedDeal, onSelectDeal, activities }) {
|
|
404
|
+
const grouped = useMemo(() => {
|
|
405
|
+
const map = {};
|
|
406
|
+
for (const s of STAGES) map[s] = [];
|
|
407
|
+
for (const d of deals) {
|
|
408
|
+
if (map[d.stage]) map[d.stage].push(d);
|
|
409
|
+
else map[d.stage] = [d];
|
|
410
|
+
}
|
|
411
|
+
return map;
|
|
412
|
+
}, [deals]);
|
|
413
|
+
|
|
414
|
+
return html`
|
|
415
|
+
<div class="ip-split" style=${{ height:'100%' }}>
|
|
416
|
+
<div class="ip-split-list" style=${{ overflowY:'auto' }} role="grid" aria-label="Sales pipeline list">
|
|
417
|
+
<div class="sl-list-head">
|
|
418
|
+
<span class="sl-list-title">Sales</span>
|
|
419
|
+
<span class="sl-list-meta">${deals.length} open</span>
|
|
420
|
+
</div>
|
|
421
|
+
${stagesWithExtras(grouped).map((s) => grouped[s]?.length > 0 ? html`
|
|
422
|
+
<div class="split-group" key=${s}>
|
|
423
|
+
<div class="split-group-head">${STAGE_LABEL[s] ?? s} · ${grouped[s].length}</div>
|
|
424
|
+
${grouped[s].map((d) => html`
|
|
425
|
+
<${DealRow}
|
|
426
|
+
key=${d.id}
|
|
427
|
+
deal=${d}
|
|
428
|
+
isSelected=${selectedDeal?.id === d.id}
|
|
429
|
+
onClick=${() => onSelectDeal(d)}
|
|
430
|
+
/>
|
|
431
|
+
`)}
|
|
432
|
+
</div>
|
|
433
|
+
` : null)}
|
|
434
|
+
</div>
|
|
435
|
+
<div class="ip-split-divider" role="separator" aria-hidden="true"></div>
|
|
436
|
+
<${DealDetail} deal=${selectedDeal} activities=${activities} />
|
|
437
|
+
</div>
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Kanban mini-avatar */
|
|
442
|
+
function KbAvatar({ owner }) {
|
|
443
|
+
const isAgent = owner === 'sales-agent';
|
|
444
|
+
const initial = isAgent ? 'S' : (owner?.[0]?.toUpperCase() ?? 'N');
|
|
445
|
+
return html`<span class=${cn('kb-mini-avatar', isAgent && 'is-agent')} aria-label=${owner ?? ''}>${initial}</span>`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Pipeline kanban mode */
|
|
449
|
+
function PipelineKanban({ deals, onStageChange }) {
|
|
450
|
+
const [dragging, setDragging] = useState(null);
|
|
451
|
+
const [dropTarget, setDropTarget] = useState(null);
|
|
452
|
+
|
|
453
|
+
const grouped = useMemo(() => {
|
|
454
|
+
const map = {};
|
|
455
|
+
for (const s of STAGES) map[s] = [];
|
|
456
|
+
for (const d of deals) {
|
|
457
|
+
if (map[d.stage]) map[d.stage].push(d);
|
|
458
|
+
else map[d.stage] = [d];
|
|
459
|
+
}
|
|
460
|
+
return map;
|
|
461
|
+
}, [deals]);
|
|
462
|
+
|
|
463
|
+
function onDragStart(d) { setDragging(d); }
|
|
464
|
+
function onDragOver(e, s) { e.preventDefault(); setDropTarget(s); }
|
|
465
|
+
function onDrop(e, s) {
|
|
466
|
+
e.preventDefault();
|
|
467
|
+
if (dragging && dragging.stage !== s) onStageChange(dragging, s);
|
|
468
|
+
setDragging(null);
|
|
469
|
+
setDropTarget(null);
|
|
470
|
+
}
|
|
471
|
+
function onDragEnd() { setDragging(null); setDropTarget(null); }
|
|
472
|
+
|
|
473
|
+
return html`
|
|
474
|
+
<div class="kb-board-wrap">
|
|
475
|
+
<div class="kb-board" role="region" aria-label="Sales pipeline board">
|
|
476
|
+
${stagesWithExtras(grouped).map((s) => {
|
|
477
|
+
const stagDeals = grouped[s] ?? [];
|
|
478
|
+
const totalVal = stagDeals.reduce((sum, d) => sum + (parseFloat(d.value) || 0), 0);
|
|
479
|
+
return html`
|
|
480
|
+
<div
|
|
481
|
+
key=${s}
|
|
482
|
+
class=${cn('kb-col', dropTarget === s && 'is-drop-target')}
|
|
483
|
+
data-stage=${s}
|
|
484
|
+
onDragOver=${(e) => onDragOver(e, s)}
|
|
485
|
+
onDrop=${(e) => onDrop(e, s)}
|
|
486
|
+
role="region"
|
|
487
|
+
aria-label=${'Stage: ' + (STAGE_LABEL[s] ?? s)}
|
|
488
|
+
>
|
|
489
|
+
<div class="kb-col-head">
|
|
490
|
+
<span class="kb-col-dot" aria-hidden="true"></span>
|
|
491
|
+
<span class="kb-col-name">${STAGE_LABEL[s] ?? s}</span>
|
|
492
|
+
<span class="kb-col-meta">${stagDeals.length} · ${fmtCurrency(totalVal)}</span>
|
|
493
|
+
</div>
|
|
494
|
+
<div class="kb-col-body">
|
|
495
|
+
${stagDeals.map((d) => html`
|
|
496
|
+
<div
|
|
497
|
+
key=${d.id}
|
|
498
|
+
class=${cn('kb-card', dragging?.id === d.id && 'is-dragging')}
|
|
499
|
+
draggable="true"
|
|
500
|
+
onDragStart=${() => onDragStart(d)}
|
|
501
|
+
onDragEnd=${onDragEnd}
|
|
502
|
+
tabIndex=${0}
|
|
503
|
+
>
|
|
504
|
+
<div class="kb-card-title">${d.title ?? d.company}</div>
|
|
505
|
+
<div class="kb-card-sub">${d.company}</div>
|
|
506
|
+
<div class="kb-card-foot">
|
|
507
|
+
<span class="kb-card-amt">${fmtCurrency(d.value)}</span>
|
|
508
|
+
<div class="kb-card-owner">
|
|
509
|
+
<span class=${cn('ux-dot', `ux-${d.next_action_mode ?? 'silent'}`)}></span>
|
|
510
|
+
<${KbAvatar} owner=${d.owner ?? d.assigned_to} />
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
`)}
|
|
515
|
+
<button class="kb-add btn-icon" type="button" aria-label=${'Add deal to ' + STAGE_LABEL[s]}>+</button>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
`;
|
|
519
|
+
})}
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Forecast view */
|
|
526
|
+
function ForecastView({ deals }) {
|
|
527
|
+
const kpis = useMemo(() => {
|
|
528
|
+
const openPipeline = deals.reduce((s, d) => s + (parseFloat(d.value) || 0), 0);
|
|
529
|
+
const weighted = deals.reduce((s, d) => s + ((parseFloat(d.value) || 0) * (d.win_probability ?? 0.5)), 0);
|
|
530
|
+
const commit = deals
|
|
531
|
+
.filter((d) => (d.stage === 'closing' || d.stage === 'negotiation') && (d.win_probability ?? 0) >= 0.70)
|
|
532
|
+
.reduce((s, d) => s + (parseFloat(d.value) || 0), 0);
|
|
533
|
+
return { openPipeline, weighted, commit, target: 300_000 };
|
|
534
|
+
}, [deals]);
|
|
535
|
+
|
|
536
|
+
const funnelRows = useMemo(() => {
|
|
537
|
+
const maxVal = Math.max(...STAGES.map((s) => {
|
|
538
|
+
return deals.filter((d) => d.stage === s).reduce((sum, d) => sum + (parseFloat(d.value) || 0), 0);
|
|
539
|
+
}), 1);
|
|
540
|
+
return STAGES.map((s) => {
|
|
541
|
+
const total = deals.filter((d) => d.stage === s).reduce((sum, d) => sum + (parseFloat(d.value) || 0), 0);
|
|
542
|
+
const wt = deals.filter((d) => d.stage === s).reduce((sum, d) => sum + ((parseFloat(d.value) || 0) * (d.win_probability ?? 0.5)), 0);
|
|
543
|
+
return { stage: s, total, wt, pctTotal: total / maxVal, pctWt: wt / maxVal };
|
|
544
|
+
});
|
|
545
|
+
}, [deals]);
|
|
546
|
+
|
|
547
|
+
// Expected close by month (Apr/May/Jun — derived from fixture)
|
|
548
|
+
const months = [
|
|
549
|
+
{ label: 'Apr', val: 180000, maxVal: 280000 },
|
|
550
|
+
{ label: 'May', val: 280000, maxVal: 280000 },
|
|
551
|
+
{ label: 'Jun', val: 46000, maxVal: 280000 },
|
|
552
|
+
];
|
|
553
|
+
const maxMonthVal = Math.max(...months.map((m) => m.val), 1);
|
|
554
|
+
|
|
555
|
+
return html`
|
|
556
|
+
<div class="sl-forecast-wrap frame-body-flush">
|
|
557
|
+
<div class="sl-forecast-kpis">
|
|
558
|
+
<div class="sl-forecast-kpi">
|
|
559
|
+
<span class="sl-kpi-k">Open pipeline</span>
|
|
560
|
+
<span class="sl-kpi-v">${fmtCurrency(kpis.openPipeline)}</span>
|
|
561
|
+
<span class="sl-kpi-sub">8 active deals</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="sl-forecast-kpi">
|
|
564
|
+
<span class="sl-kpi-k">Weighted</span>
|
|
565
|
+
<span class="sl-kpi-v">${fmtCurrency(kpis.weighted)}</span>
|
|
566
|
+
<span class="sl-kpi-sub">by win prob.</span>
|
|
567
|
+
</div>
|
|
568
|
+
<div class="sl-forecast-kpi">
|
|
569
|
+
<span class="sl-kpi-k">Commit</span>
|
|
570
|
+
<span class="sl-kpi-v">${fmtCurrency(kpis.commit)}</span>
|
|
571
|
+
<span class="sl-kpi-sub">closing + neg ≥70%</span>
|
|
572
|
+
</div>
|
|
573
|
+
<div class="sl-forecast-kpi">
|
|
574
|
+
<span class="sl-kpi-k">Quarter target</span>
|
|
575
|
+
<span class="sl-kpi-v">${fmtCurrency(kpis.target)}</span>
|
|
576
|
+
<span class="sl-kpi-sub">Q2 2026</span>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
<div class="sl-forecast-card">
|
|
581
|
+
<div class="sl-forecast-card-h">Weighted by stage</div>
|
|
582
|
+
${funnelRows.map((r) => html`
|
|
583
|
+
<div class="sl-funnel-row" key=${r.stage}>
|
|
584
|
+
<span class="sl-funnel-name">${STAGE_LABEL[r.stage]}</span>
|
|
585
|
+
<div class="sl-funnel-bar">
|
|
586
|
+
<div class="sl-funnel-bar-fill" style=${{ width: `${r.pctTotal * 100}%` }}></div>
|
|
587
|
+
<div class="sl-funnel-bar-wt" style=${{ width: `${r.pctWt * 100}%` }}></div>
|
|
588
|
+
</div>
|
|
589
|
+
<span class="sl-funnel-val">${fmtCurrency(r.total)} · ${fmtCurrency(r.wt)}</span>
|
|
590
|
+
</div>
|
|
591
|
+
`)}
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<div class="sl-forecast-card">
|
|
595
|
+
<div class="sl-forecast-card-h">Expected close by month</div>
|
|
596
|
+
<div class="sl-months">
|
|
597
|
+
${months.map((m) => html`
|
|
598
|
+
<div class="sl-month" key=${m.label}>
|
|
599
|
+
<span class="sl-month-val">${fmtCurrency(m.val)}</span>
|
|
600
|
+
<div class="sl-month-bar-wrap">
|
|
601
|
+
<div class="sl-month-bar" style=${{ height: `${Math.round((m.val / maxMonthVal) * 64)}px` }}></div>
|
|
602
|
+
</div>
|
|
603
|
+
<span class="sl-month-lab">${m.label}</span>
|
|
604
|
+
</div>
|
|
605
|
+
`)}
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Won view */
|
|
613
|
+
function WonView({ wonDeals }) {
|
|
614
|
+
const kpis = useMemo(() => {
|
|
615
|
+
const total = wonDeals.reduce((s, d) => s + (parseFloat(d.value) || 0), 0);
|
|
616
|
+
const avg = wonDeals.length ? total / wonDeals.length : 0;
|
|
617
|
+
return { total, avg, cycle: 34, winRate: 41 };
|
|
618
|
+
}, [wonDeals]);
|
|
619
|
+
|
|
620
|
+
return html`
|
|
621
|
+
<div class="sl-won-wrap frame-body-flush">
|
|
622
|
+
<div class="sl-won-kpis">
|
|
623
|
+
<div class="sl-forecast-kpi">
|
|
624
|
+
<span class="sl-kpi-k">Won this quarter</span>
|
|
625
|
+
<span class="sl-kpi-v" style=${{ color: 'var(--live)' }}>${fmtCurrency(kpis.total)}</span>
|
|
626
|
+
<span class="sl-kpi-sub">${wonDeals.length} deals</span>
|
|
627
|
+
</div>
|
|
628
|
+
<div class="sl-forecast-kpi">
|
|
629
|
+
<span class="sl-kpi-k">Avg deal size</span>
|
|
630
|
+
<span class="sl-kpi-v">${fmtCurrency(kpis.avg)}</span>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="sl-forecast-kpi">
|
|
633
|
+
<span class="sl-kpi-k">Avg cycle</span>
|
|
634
|
+
<span class="sl-kpi-v">${kpis.cycle}d</span>
|
|
635
|
+
</div>
|
|
636
|
+
<div class="sl-forecast-kpi">
|
|
637
|
+
<span class="sl-kpi-k">Win rate</span>
|
|
638
|
+
<span class="sl-kpi-v">${kpis.winRate}%</span>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
<div class="sl-won-table-wrap">
|
|
643
|
+
<table class="sl-won-table" role="grid" aria-label="Won deals">
|
|
644
|
+
<thead>
|
|
645
|
+
<tr>
|
|
646
|
+
<th>Deal</th>
|
|
647
|
+
<th>Company</th>
|
|
648
|
+
<th>Source</th>
|
|
649
|
+
<th>Owner</th>
|
|
650
|
+
<th>Closed</th>
|
|
651
|
+
<th style=${{ textAlign:'right' }}>Value</th>
|
|
652
|
+
</tr>
|
|
653
|
+
</thead>
|
|
654
|
+
<tbody>
|
|
655
|
+
${wonDeals.map((d) => html`
|
|
656
|
+
<tr key=${d.id}>
|
|
657
|
+
<td>${d.title ?? d.company}</td>
|
|
658
|
+
<td style=${{ color:'var(--fg-muted)' }}>${d.company}</td>
|
|
659
|
+
<td><span class="sl-won-badge">${d.source ?? '—'}</span></td>
|
|
660
|
+
<td style=${{ color:'var(--fg-muted)', fontSize:'0.75rem' }}>${d.owner ?? d.assigned_to ?? '—'}</td>
|
|
661
|
+
<td style=${{ color:'var(--fg-muted)', fontFamily:'var(--font-mono)', fontSize:'0.72rem' }}>${d.closed ?? d.last_contact ?? '—'}</td>
|
|
662
|
+
<td style=${{ textAlign:'right' }}><span class="sl-won-amt">${fmtCurrency(d.value)}</span></td>
|
|
663
|
+
</tr>
|
|
664
|
+
`)}
|
|
665
|
+
</tbody>
|
|
666
|
+
</table>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ─── Loading / empty / error states ──────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
function LoadingState() {
|
|
675
|
+
return html`
|
|
676
|
+
<div class="atelier-state is-loading" id="view-stage">
|
|
677
|
+
<span class="atelier-spin" aria-hidden="true"></span>
|
|
678
|
+
<span>Loading deals…</span>
|
|
679
|
+
</div>
|
|
680
|
+
`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function EmptyState() {
|
|
684
|
+
return html`
|
|
685
|
+
<div class="atelier-state is-empty" id="view-stage">
|
|
686
|
+
<span>No deals yet</span>
|
|
687
|
+
<button class="btn btn-sm" type="button" style=${{ marginTop:'8px' }}>New deal</button>
|
|
688
|
+
</div>
|
|
689
|
+
`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function ErrorState({ error }) {
|
|
693
|
+
return html`
|
|
694
|
+
<div class="atelier-state is-error" id="view-stage">
|
|
695
|
+
<span>Failed to load deals</span>
|
|
696
|
+
<span style=${{ fontSize:'0.72rem', color:'var(--fg-muted)', marginTop:'4px' }}>${error}</span>
|
|
697
|
+
</div>
|
|
698
|
+
`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function StreamingState() {
|
|
702
|
+
return html`
|
|
703
|
+
<div class="atelier-state is-streaming" id="view-stage">
|
|
704
|
+
<span class="atelier-prog" role="status" aria-live="polite">Sales agent running…</span>
|
|
705
|
+
</div>
|
|
706
|
+
`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ─── SalesView root ───────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
export function SalesView({ activeFeature }) {
|
|
712
|
+
// View state: 0=Pipeline | 1=Forecast | 2=Won
|
|
713
|
+
const [activeView, setActiveView] = useState(() => {
|
|
714
|
+
const p = new URLSearchParams(window.location.search).get('view');
|
|
715
|
+
const v = parseInt(p ?? '0', 10);
|
|
716
|
+
return [0, 1, 2].includes(v) ? v : 0;
|
|
717
|
+
});
|
|
718
|
+
const [pipeMode, setPipeMode] = useState('list'); // 'list' | 'kanban'
|
|
719
|
+
const [selectedDeal, setSelectedDeal] = useState(null);
|
|
720
|
+
const qc = useQueryClient();
|
|
721
|
+
|
|
722
|
+
// ── Data queries ────────────────────────────────────────────────────────────
|
|
723
|
+
const openDealsQ = useQuery({ queryKey: QK.openDeals, queryFn: fetchOpenDeals });
|
|
724
|
+
const wonDealsQ = useQuery({ queryKey: QK.wonDeals, queryFn: fetchWonDeals, enabled: activeView === 2 });
|
|
725
|
+
|
|
726
|
+
const activitiesQ = useQuery({
|
|
727
|
+
queryKey: QK.activities(selectedDeal?.id ?? null),
|
|
728
|
+
queryFn: () => fetchActivities(selectedDeal?.id),
|
|
729
|
+
enabled: !!selectedDeal?.id,
|
|
730
|
+
staleTime: 60_000,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const deals = openDealsQ.data ?? [];
|
|
734
|
+
|
|
735
|
+
// ── db-updated refresh ──────────────────────────────────────────────────────
|
|
736
|
+
useEffect(() => {
|
|
737
|
+
function onDbUpdated() {
|
|
738
|
+
qc.invalidateQueries({ queryKey: ['sales'] });
|
|
739
|
+
}
|
|
740
|
+
window.addEventListener('db-updated', onDbUpdated);
|
|
741
|
+
return () => window.removeEventListener('db-updated', onDbUpdated);
|
|
742
|
+
}, [qc]);
|
|
743
|
+
|
|
744
|
+
// ── activeFeature → view switching (side-menu item clicks) ─────────────────
|
|
745
|
+
useEffect(() => {
|
|
746
|
+
if (!activeFeature) return;
|
|
747
|
+
if (activeFeature === 'v:pipeline') setActiveView(0);
|
|
748
|
+
else if (activeFeature === 'v:forecast') setActiveView(1);
|
|
749
|
+
else if (activeFeature === 'v:won') setActiveView(2);
|
|
750
|
+
else if (activeFeature === 'seg:list') setPipeMode('list');
|
|
751
|
+
else if (activeFeature === 'seg:kanban') setPipeMode('kanban');
|
|
752
|
+
}, [activeFeature]);
|
|
753
|
+
|
|
754
|
+
// ── Pre-select hero deal (D-05 — Catalog onboarding · Chocolate City) ──────
|
|
755
|
+
useEffect(() => {
|
|
756
|
+
if (deals.length > 0 && !selectedDeal) {
|
|
757
|
+
const hero = deals.find((d) => d.id === 'D-05') ?? deals[0];
|
|
758
|
+
setSelectedDeal(hero);
|
|
759
|
+
}
|
|
760
|
+
}, [deals]);
|
|
761
|
+
|
|
762
|
+
// ── setMenu publish ─────────────────────────────────────────────────────────
|
|
763
|
+
useEffect(() => {
|
|
764
|
+
if (isStandalone()) return;
|
|
765
|
+
const items = buildSalesMenu(activeView, pipeMode, deals);
|
|
766
|
+
setMenu(items).catch(() => {/* ignore */});
|
|
767
|
+
}, [activeView, pipeMode, deals]);
|
|
768
|
+
|
|
769
|
+
// ── Stage change mutation (kanban drag) ─────────────────────────────────────
|
|
770
|
+
const stageChange = useMutation({
|
|
771
|
+
mutationFn: async ({ deal, newStage }) => {
|
|
772
|
+
if (isStandalone()) return;
|
|
773
|
+
await hostDbExec(
|
|
774
|
+
`UPDATE sales_deals SET stage = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
775
|
+
[newStage, deal.id]
|
|
776
|
+
);
|
|
777
|
+
},
|
|
778
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: QK.openDeals }),
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// ── Head label ──────────────────────────────────────────────────────────────
|
|
782
|
+
const headLabel = activeView === 0 ? `Sales · ${deals.length} open`
|
|
783
|
+
: activeView === 1 ? 'Forecast'
|
|
784
|
+
: 'Won';
|
|
785
|
+
|
|
786
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
787
|
+
let body;
|
|
788
|
+
|
|
789
|
+
if (activeView === 0) {
|
|
790
|
+
// Pipeline view
|
|
791
|
+
if (openDealsQ.isLoading) {
|
|
792
|
+
body = html`<${LoadingState} />`;
|
|
793
|
+
} else if (openDealsQ.isError) {
|
|
794
|
+
body = html`<${ErrorState} error=${openDealsQ.error?.message ?? 'unknown'} />`;
|
|
795
|
+
} else if (deals.length === 0) {
|
|
796
|
+
body = html`<${EmptyState} />`;
|
|
797
|
+
} else if (pipeMode === 'kanban') {
|
|
798
|
+
body = html`<${PipelineKanban}
|
|
799
|
+
deals=${deals}
|
|
800
|
+
onStageChange=${(deal, newStage) => stageChange.mutate({ deal, newStage })}
|
|
801
|
+
/>`;
|
|
802
|
+
} else {
|
|
803
|
+
body = html`<${PipelineList}
|
|
804
|
+
deals=${deals}
|
|
805
|
+
selectedDeal=${selectedDeal}
|
|
806
|
+
onSelectDeal=${setSelectedDeal}
|
|
807
|
+
activities=${activitiesQ.data ?? []}
|
|
808
|
+
/>`;
|
|
809
|
+
}
|
|
810
|
+
} else if (activeView === 1) {
|
|
811
|
+
body = html`<${ForecastView} deals=${deals} />`;
|
|
812
|
+
} else {
|
|
813
|
+
// Won view
|
|
814
|
+
if (wonDealsQ.isLoading) {
|
|
815
|
+
body = html`<${LoadingState} />`;
|
|
816
|
+
} else if (wonDealsQ.isError) {
|
|
817
|
+
body = html`<${ErrorState} error=${wonDealsQ.error?.message ?? 'unknown'} />`;
|
|
818
|
+
} else {
|
|
819
|
+
body = html`<${WonView} wonDeals=${wonDealsQ.data ?? WON_DEALS_FIXTURE} />`;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return html`
|
|
824
|
+
<div class="frame" style=${{ height:'100%', display:'flex', flexDirection:'column' }}>
|
|
825
|
+
<div class="frame-head" style=${{ display:'flex', alignItems:'center', gap:'8px' }}>
|
|
826
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
827
|
+
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline>
|
|
828
|
+
<polyline points="16 7 22 7 22 13"></polyline>
|
|
829
|
+
</svg>
|
|
830
|
+
<span>${headLabel}</span>
|
|
831
|
+
</div>
|
|
832
|
+
<div class="frame-body-flush" id="view-stage" style=${{ flex:1, overflow:'hidden' }}>
|
|
833
|
+
${body}
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
`;
|
|
837
|
+
}
|