@startsimpli/funnels 0.4.14 → 0.4.16

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.
@@ -0,0 +1,739 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * FUNNELS — the whole screen, once, for every surface that has funnels.
5
+ *
6
+ * bd startsim-em5mn, under CLAUDE.md rule 9 and the share-page-composers
7
+ * convention: foundry-web, the tenant template and market-web mount THIS
8
+ * rather than each rebuilding a list, a builder, a run history and a results
9
+ * table. The precedent is the tenant template's planning page mounting
10
+ * `PlanningPage` from @startsimpli/integrations — the app file's whole share is
11
+ * a heading and the mount.
12
+ *
13
+ * WHY IT LIVES IN @startsimpli/funnels AND NOT @startsimpli/ui.
14
+ * `packages/api/src/types/funnel.ts` records the reason in its own words: the
15
+ * funnel types are duplicated into @startsimpli/api rather than imported
16
+ * "because every app installs @startsimpli/api and only two install
17
+ * @startsimpli/funnels (which drags @xyflow/react and @dnd-kit with it)". A
18
+ * composer in @startsimpli/ui would invert exactly that — the library every app
19
+ * installs would depend on the heavy domain package. Here, the dependency runs
20
+ * the harmless way: funnels takes ONE table from ui, as an OPTIONAL PEER, and
21
+ * only on this subpath. Hence `@startsimpli/funnels/page` rather than the root
22
+ * barrel — the same reasoning `packages/ui/src/foundry/index.ts` writes down
23
+ * for its own subpath ("A SUBPATH AND NOT THE ROOT BARREL, deliberately"), so
24
+ * raise and market keep importing @startsimpli/funnels without pulling ui in.
25
+ *
26
+ * EVERY LIST IS PAGED BY THE SERVER (repo rule #8). The funnel list and the
27
+ * results list each hold one page and ask for the next; neither ever holds the
28
+ * corpus. There is NO client-side sort: the control-plane list is ordered
29
+ * `("-created_at", "-id")` by `service_api.list_funnels` and reads no
30
+ * `ordering` param, so offering a sortable header would be a control that
31
+ * lies. bd startsim-em5mn.3 tracks the server-side ordering.
32
+ *
33
+ * THERE IS NO PREVIEW, DELIBERATELY. `core/engine.ts` evaluates an in-memory
34
+ * array and the corpus is ~208k public rows, so an in-browser preview is out on
35
+ * its face. The server-side alternative is worse than absent here: no `preview`
36
+ * action is mounted on FoundryViewSet at all (verified against prod
37
+ * 2026-09-18), and the client's server-side preview call would make the CALLER
38
+ * post the entities — the same paging-into-the-browser by another name. THE RUN IS THE
39
+ * PREVIEW: `POST funnels/<id>/runs/` accepts an `entity_ids` subset, and the
40
+ * run's `stage_breakdown` is the per-stage funnel chart, computed server-side.
41
+ * bd startsim-em5mn.2 tracks a sampled-preview route.
42
+ */
43
+ import * as React from 'react';
44
+ import { UnifiedTable } from '@startsimpli/ui/table';
45
+
46
+ import { FunnelStageBuilder } from '../components/FunnelStageBuilder';
47
+ import { FunnelRunHistory } from '../components/FunnelRunHistory';
48
+ import type { FunnelApiClient } from '../api/client';
49
+ import type {
50
+ FieldDefinition,
51
+ Funnel,
52
+ FunnelRun,
53
+ FunnelStage,
54
+ FunnelStatus,
55
+ } from '../types';
56
+ import type {
57
+ FunnelListRow,
58
+ FunnelResultRow,
59
+ FunnelsPageProps,
60
+ } from './types';
61
+
62
+ const STATUSES: FunnelStatus[] = ['draft', 'active', 'paused', 'archived'];
63
+ const DEFAULT_PAGE_SIZE = 25;
64
+
65
+ /** The literal the results filter uses for "was never excluded". */
66
+ const STAGE_REACHED_END = 'end';
67
+
68
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
69
+
70
+ /**
71
+ * Stages, with ids the server will accept.
72
+ *
73
+ * `FunnelStageBuilder` mints an id for a stage the moment it is added —
74
+ * `stage-<timestamp>-<random>` — because its list needs a stable React key
75
+ * before anything is saved. `FunnelStageSerializer.id` is a `UUIDField`, so
76
+ * sending that back is a 400: {'id': ['Must be a valid UUID.']}. Verified
77
+ * against a live control plane on 2026-09-18 — the same PATCH without the id
78
+ * is a 200.
79
+ *
80
+ * So a client-minted id is dropped and a server-assigned one is kept, which is
81
+ * exactly the distinction the reconciler needs: it updates the stage a client
82
+ * NAMES and creates one it does not, and keeping the real ids is what stops an
83
+ * edit from reissuing every stage id underneath the funnel — `stage_stats` is
84
+ * keyed on stage id and results record `reached_stage_id` (bd startsim-0wmf8.1).
85
+ */
86
+ function stagesForSave(stages: FunnelStage[] = []): FunnelStage[] {
87
+ return stages.map((stage) => {
88
+ if (stage.id && UUID.test(stage.id)) return stage;
89
+ const { id: _minted, ...rest } = stage;
90
+ return rest as FunnelStage;
91
+ });
92
+ }
93
+
94
+ function when(value?: string | Date | null): string {
95
+ if (!value) return '—';
96
+ const d = value instanceof Date ? value : new Date(value);
97
+ return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString();
98
+ }
99
+
100
+ function count(value?: number | null): string {
101
+ return typeof value === 'number' ? value.toLocaleString() : '—';
102
+ }
103
+
104
+ /**
105
+ * One request in flight at a time, latest wins.
106
+ *
107
+ * Typing in the search box fires a request per keystroke and they do not come
108
+ * back in order; without the guard a slow early response overwrites a fast late
109
+ * one and the table shows results for a query the box no longer holds.
110
+ */
111
+ function useLatest<T>(
112
+ load: () => Promise<T>,
113
+ deps: React.DependencyList,
114
+ ): { data: T | null; error: string | null; loading: boolean } {
115
+ const [data, setData] = React.useState<T | null>(null);
116
+ const [error, setError] = React.useState<string | null>(null);
117
+ const [loading, setLoading] = React.useState(true);
118
+ const seq = React.useRef(0);
119
+
120
+ React.useEffect(() => {
121
+ const ticket = ++seq.current;
122
+ let live = true;
123
+ setLoading(true);
124
+ load()
125
+ .then((result) => {
126
+ if (!live || ticket !== seq.current) return;
127
+ setData(result);
128
+ setError(null);
129
+ })
130
+ .catch((e: unknown) => {
131
+ if (!live || ticket !== seq.current) return;
132
+ setError(e instanceof Error ? e.message : String(e));
133
+ })
134
+ .finally(() => {
135
+ if (live && ticket === seq.current) setLoading(false);
136
+ });
137
+ return () => {
138
+ live = false;
139
+ };
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ }, [...deps]);
142
+
143
+ return { data, error, loading };
144
+ }
145
+
146
+ /**
147
+ * The last non-empty value seen.
148
+ *
149
+ * The rule editor's field picker is a CONTROLLED select over the vocabulary.
150
+ * While a refetch is in flight `useLatest` still holds the previous payload,
151
+ * but on the first load — and on a genuine change of entity type — the list is
152
+ * briefly empty, and a controlled select whose options do not contain its value
153
+ * renders blank. The rule's stored `field` survives that (no change event
154
+ * fires), but the author watches their field unselect itself, which is worse
155
+ * than a stale list for the half-second it takes.
156
+ */
157
+ function useSticky<T>(value: T[]): T[] {
158
+ const held = React.useRef<T[]>(value);
159
+ if (value.length > 0) held.current = value;
160
+ return value.length > 0 ? value : held.current;
161
+ }
162
+
163
+ const label = 'block text-xs font-medium text-muted-foreground';
164
+ const control =
165
+ 'h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground';
166
+ const button =
167
+ 'inline-flex h-9 items-center rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:opacity-50';
168
+ const primary =
169
+ 'inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50';
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // The list
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function FunnelList({
176
+ client,
177
+ canManage,
178
+ pageSize,
179
+ onOpen,
180
+ }: {
181
+ client: FunnelApiClient;
182
+ canManage: boolean;
183
+ pageSize: number;
184
+ onOpen: (funnel: FunnelListRow) => void;
185
+ }) {
186
+ const [status, setStatus] = React.useState<FunnelStatus | ''>('');
187
+ const [search, setSearch] = React.useState('');
188
+ const [page, setPage] = React.useState(1);
189
+ const [creating, setCreating] = React.useState(false);
190
+ const [name, setName] = React.useState('');
191
+ const [entityType, setEntityType] = React.useState('');
192
+ const [saving, setSaving] = React.useState(false);
193
+
194
+ // Every narrowing is a QUERY PARAM. `status`/`search` are what
195
+ // `service_api.list_funnels` reads; nothing here filters rows it already
196
+ // holds, because it only ever holds one page of them.
197
+ const listing = useLatest(
198
+ () =>
199
+ client.listFunnels<unknown>({
200
+ page,
201
+ pageSize,
202
+ ...(status ? { status } : {}),
203
+ ...(search ? { search } : {}),
204
+ }),
205
+ [client, page, pageSize, status, search],
206
+ );
207
+
208
+ const narrow = (apply: () => void) => {
209
+ apply();
210
+ setPage(1); // a narrower set has a different page 1
211
+ };
212
+
213
+ const rows = (listing.data?.results ?? []) as FunnelListRow[];
214
+
215
+ const create = async (e: React.FormEvent) => {
216
+ e.preventDefault();
217
+ if (!name.trim()) return;
218
+ setSaving(true);
219
+ try {
220
+ const made = await client.createFunnel({
221
+ name: name.trim(),
222
+ status: 'draft',
223
+ entityType: entityType.trim() || 'contact',
224
+ stages: [],
225
+ });
226
+ setCreating(false);
227
+ setName('');
228
+ setEntityType('');
229
+ onOpen(made as FunnelListRow);
230
+ } finally {
231
+ setSaving(false);
232
+ }
233
+ };
234
+
235
+ return (
236
+ <div className="space-y-4" data-testid="funnels-list">
237
+ <div className="flex flex-wrap items-end gap-3">
238
+ <div>
239
+ <label className={label} htmlFor="funnels-search">
240
+ Search
241
+ </label>
242
+ <input
243
+ id="funnels-search"
244
+ className={`${control} w-56`}
245
+ placeholder="Search funnels by name"
246
+ value={search}
247
+ onChange={(e) => narrow(() => setSearch(e.target.value))}
248
+ />
249
+ </div>
250
+ <div>
251
+ <label className={label} htmlFor="funnels-status">
252
+ Status
253
+ </label>
254
+ <select
255
+ id="funnels-status"
256
+ className={control}
257
+ value={status}
258
+ onChange={(e) => narrow(() => setStatus(e.target.value as FunnelStatus | ''))}
259
+ >
260
+ <option value="">Any status</option>
261
+ {STATUSES.map((s) => (
262
+ <option key={s} value={s}>
263
+ {s}
264
+ </option>
265
+ ))}
266
+ </select>
267
+ </div>
268
+ <span className="flex-1" />
269
+ {canManage && (
270
+ <button type="button" className={primary} onClick={() => setCreating((v) => !v)}>
271
+ New funnel
272
+ </button>
273
+ )}
274
+ </div>
275
+
276
+ {creating && canManage && (
277
+ <form className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3" onSubmit={create}>
278
+ <div>
279
+ <label className={label} htmlFor="new-funnel-name">
280
+ Name
281
+ </label>
282
+ <input
283
+ id="new-funnel-name"
284
+ className={`${control} w-56`}
285
+ value={name}
286
+ onChange={(e) => setName(e.target.value)}
287
+ />
288
+ </div>
289
+ <div>
290
+ <label className={label} htmlFor="new-funnel-entity-type">
291
+ Entity type
292
+ </label>
293
+ <input
294
+ id="new-funnel-entity-type"
295
+ className={control}
296
+ placeholder="contact"
297
+ value={entityType}
298
+ onChange={(e) => setEntityType(e.target.value)}
299
+ />
300
+ </div>
301
+ <button type="submit" className={primary} disabled={!name.trim() || saving}>
302
+ {saving ? 'Creating…' : 'Create'}
303
+ </button>
304
+ </form>
305
+ )}
306
+
307
+ {listing.error && (
308
+ <p className="text-sm text-red-600" role="alert">
309
+ Couldn’t load funnels: {listing.error}
310
+ </p>
311
+ )}
312
+
313
+ <UnifiedTable<FunnelListRow>
314
+ tableId="funnels"
315
+ data={rows}
316
+ getRowId={(row) => row.id}
317
+ loading={listing.loading}
318
+ onRowClick={onOpen}
319
+ emptyState={
320
+ <p className="p-6 text-center text-sm text-muted-foreground">
321
+ No funnels yet. A funnel is a set of stages this foundry runs over its own
322
+ entities.
323
+ </p>
324
+ }
325
+ pagination={{
326
+ enabled: true,
327
+ serverSide: true,
328
+ pageSize,
329
+ currentPage: page,
330
+ totalCount: listing.data?.count ?? 0,
331
+ onPageChange: setPage,
332
+ }}
333
+ columns={[
334
+ { id: 'name', header: 'Name', accessorKey: 'name', sortable: false },
335
+ { id: 'status', header: 'Status', accessorKey: 'status', sortable: false },
336
+ { id: 'entityType', header: 'Entity type', sortable: false, cell: (r) => r.entityType || '—' },
337
+ { id: 'stages', header: 'Stages', sortable: false, cell: (r) => count(r.stageCount ?? r.stages?.length) },
338
+ { id: 'runs', header: 'Runs', sortable: false, cell: (r) => count(r.totalRuns) },
339
+ { id: 'matched', header: 'Last matched', sortable: false, cell: (r) => count(r.lastRunMatched) },
340
+ { id: 'lastRun', header: 'Last run', sortable: false, cell: (r) => when(r.lastRunAt) },
341
+ ]}
342
+ />
343
+ </div>
344
+ );
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // The results of one run
349
+ // ---------------------------------------------------------------------------
350
+
351
+ function RunResults({
352
+ client,
353
+ funnel,
354
+ runId,
355
+ pageSize,
356
+ }: {
357
+ client: FunnelApiClient;
358
+ funnel: Funnel;
359
+ runId: string;
360
+ pageSize: number;
361
+ }) {
362
+ const [stage, setStage] = React.useState('');
363
+ const [outcome, setOutcome] = React.useState('');
364
+ const [page, setPage] = React.useState(1);
365
+
366
+ // `stage` and `matched` are SERVER filters (`service_api.list_results`):
367
+ // a stage id means "stopped here", 'end' means "got all the way through".
368
+ // Filtering here instead would mean holding every result in the browser.
369
+ const listing = useLatest(
370
+ () =>
371
+ client.getFunnelResults<unknown>(runId, {
372
+ page,
373
+ pageSize,
374
+ funnelId: funnel.id,
375
+ ...(stage ? { stage } : {}),
376
+ ...(outcome ? { matched: outcome === 'true' } : {}),
377
+ }),
378
+ [client, runId, funnel.id, page, pageSize, stage, outcome],
379
+ );
380
+
381
+ const narrow = (apply: () => void) => {
382
+ apply();
383
+ setPage(1);
384
+ };
385
+
386
+ const rows = (listing.data?.results ?? []) as unknown as FunnelResultRow[];
387
+
388
+ return (
389
+ <div className="space-y-4" data-testid="funnel-results">
390
+ <div className="flex flex-wrap items-end gap-3">
391
+ <div>
392
+ <label className={label} htmlFor="results-stage">
393
+ Stage reached
394
+ </label>
395
+ <select
396
+ id="results-stage"
397
+ className={control}
398
+ value={stage}
399
+ onChange={(e) => narrow(() => setStage(e.target.value))}
400
+ >
401
+ <option value="">Any stage</option>
402
+ {[...(funnel.stages ?? [])]
403
+ .sort((a, b) => a.order - b.order)
404
+ .map((s: FunnelStage) => (
405
+ <option key={s.id} value={s.id}>
406
+ Stopped at {s.name}
407
+ </option>
408
+ ))}
409
+ <option value={STAGE_REACHED_END}>Reached the end</option>
410
+ </select>
411
+ </div>
412
+ <div>
413
+ <label className={label} htmlFor="results-outcome">
414
+ Outcome
415
+ </label>
416
+ <select
417
+ id="results-outcome"
418
+ className={control}
419
+ value={outcome}
420
+ onChange={(e) => narrow(() => setOutcome(e.target.value))}
421
+ >
422
+ <option value="">Any outcome</option>
423
+ <option value="true">Matched</option>
424
+ <option value="false">Excluded</option>
425
+ </select>
426
+ </div>
427
+ </div>
428
+
429
+ {listing.error && (
430
+ <p className="text-sm text-red-600" role="alert">
431
+ Couldn’t load results: {listing.error}
432
+ </p>
433
+ )}
434
+
435
+ <UnifiedTable<FunnelResultRow>
436
+ tableId="funnel-results"
437
+ data={rows}
438
+ getRowId={(row) => row.id}
439
+ loading={listing.loading}
440
+ emptyState={
441
+ <p className="p-6 text-center text-sm text-muted-foreground">
442
+ This run produced no rows under that filter.
443
+ </p>
444
+ }
445
+ pagination={{
446
+ enabled: true,
447
+ serverSide: true,
448
+ pageSize,
449
+ currentPage: page,
450
+ totalCount: listing.data?.count ?? 0,
451
+ onPageChange: setPage,
452
+ }}
453
+ columns={[
454
+ { id: 'entityId', header: 'Entity', sortable: false, cell: (r) => r.entityId ?? '—' },
455
+ { id: 'entityType', header: 'Type', sortable: false, cell: (r) => r.entityType ?? '—' },
456
+ {
457
+ id: 'matched',
458
+ header: 'Outcome',
459
+ sortable: false,
460
+ cell: (r) => (r.matched ? 'Matched' : 'Excluded'),
461
+ },
462
+ {
463
+ id: 'stageReached',
464
+ header: 'Stage reached',
465
+ sortable: false,
466
+ // The name the RUN recorded, not the stage's current name — a run
467
+ // that ran against "Angels" keeps saying "Angels" (breakdown.py).
468
+ cell: (r) => r.stageReached?.name ?? (r.matched ? 'Reached the end' : '—'),
469
+ },
470
+ {
471
+ id: 'tags',
472
+ header: 'Tags',
473
+ sortable: false,
474
+ cell: (r) => (r.accumulatedTags?.length ? r.accumulatedTags.join(', ') : '—'),
475
+ },
476
+ { id: 'createdAt', header: 'When', sortable: false, cell: (r) => when(r.createdAt) },
477
+ ]}
478
+ />
479
+ </div>
480
+ );
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // One funnel
485
+ // ---------------------------------------------------------------------------
486
+
487
+ type Tab = 'stages' | 'runs' | 'results';
488
+
489
+ function FunnelDetail({
490
+ client,
491
+ fieldsClient,
492
+ funnelId,
493
+ canManage,
494
+ pageSize,
495
+ onBack,
496
+ }: {
497
+ client: FunnelApiClient;
498
+ fieldsClient: FunnelApiClient;
499
+ funnelId: string;
500
+ canManage: boolean;
501
+ pageSize: number;
502
+ onBack: () => void;
503
+ }) {
504
+ const [tab, setTab] = React.useState<Tab>('stages');
505
+ const [draft, setDraft] = React.useState<Funnel | null>(null);
506
+ const [activeRun, setActiveRun] = React.useState<FunnelRun | null>(null);
507
+ const [resultsRun, setResultsRun] = React.useState<string | null>(null);
508
+ const [busy, setBusy] = React.useState<string | null>(null);
509
+ const [problem, setProblem] = React.useState<string | null>(null);
510
+
511
+ const loaded = useLatest(() => client.getFunnel(funnelId), [client, funnelId]);
512
+
513
+ // The vocabulary comes from the INJECTED fields client. It is a separate
514
+ // client because `/fields/` is only mounted on central — the control plane's
515
+ // `funnels/fields/` is shadowed by its `funnels/<id>/` action and answers
516
+ // "funnel not found". Never a hand-written list: the same registry validates
517
+ // the rule on POST, so a picker built from anything else offers fields that
518
+ // 400 on save.
519
+ const vocabulary = useLatest(
520
+ () => fieldsClient.getFields(draft?.entityType || undefined),
521
+ [fieldsClient, draft?.entityType],
522
+ );
523
+
524
+ // Sticky, so a refetch on a changed entity type does not blank the field
525
+ // picker under an author who is mid-rule.
526
+ const fieldRegistry = useSticky(
527
+ (vocabulary.data?.fields ?? []) as FieldDefinition[],
528
+ );
529
+
530
+ React.useEffect(() => {
531
+ if (loaded.data) {
532
+ setDraft(loaded.data as Funnel);
533
+ const last = (loaded.data as FunnelListRow).lastRunId;
534
+ setResultsRun((current) => current ?? last ?? null);
535
+ }
536
+ }, [loaded.data]);
537
+
538
+ const runNow = async () => {
539
+ setBusy('run');
540
+ setProblem(null);
541
+ try {
542
+ // The id this POST returns is the id that completes — the run row is
543
+ // created empty and adopted by the task (bd startsim-qv6pk). So the page
544
+ // follows THIS id rather than guessing at the newest run afterwards.
545
+ const run = await client.runFunnel(funnelId, { triggerType: 'manual' });
546
+ setActiveRun(run);
547
+ setResultsRun(run.id);
548
+ setTab('runs');
549
+ } catch (e) {
550
+ setProblem(e instanceof Error ? e.message : String(e));
551
+ } finally {
552
+ setBusy(null);
553
+ }
554
+ };
555
+
556
+ const save = async () => {
557
+ if (!draft) return;
558
+ setBusy('save');
559
+ setProblem(null);
560
+ try {
561
+ const saved = await client.updateFunnel(funnelId, {
562
+ name: draft.name,
563
+ status: draft.status,
564
+ entityType: draft.entityType,
565
+ stages: stagesForSave(draft.stages),
566
+ });
567
+ setDraft(saved as Funnel);
568
+ } catch (e) {
569
+ setProblem(e instanceof Error ? e.message : String(e));
570
+ } finally {
571
+ setBusy(null);
572
+ }
573
+ };
574
+
575
+ if (loaded.error) {
576
+ return (
577
+ <div className="space-y-3">
578
+ <button type="button" className={button} onClick={onBack}>
579
+ ← All funnels
580
+ </button>
581
+ <p className="text-sm text-red-600" role="alert">
582
+ Couldn’t load this funnel: {loaded.error}
583
+ </p>
584
+ </div>
585
+ );
586
+ }
587
+
588
+ if (!draft) {
589
+ return <p className="text-sm text-muted-foreground">Loading funnel…</p>;
590
+ }
591
+
592
+ const tabs: Array<[Tab, string]> = [
593
+ ['stages', 'Stages'],
594
+ ['runs', 'Runs'],
595
+ ['results', 'Results'],
596
+ ];
597
+
598
+ return (
599
+ <div className="space-y-5" data-testid="funnel-detail">
600
+ <div className="flex flex-wrap items-center gap-3">
601
+ <button type="button" className={button} onClick={onBack}>
602
+ ← All funnels
603
+ </button>
604
+ <input
605
+ className={`${control} w-64 font-medium`}
606
+ aria-label="Funnel name"
607
+ value={draft.name}
608
+ disabled={!canManage}
609
+ onChange={(e) => setDraft({ ...draft, name: e.target.value })}
610
+ />
611
+ <select
612
+ className={control}
613
+ aria-label="Funnel status"
614
+ value={draft.status}
615
+ disabled={!canManage}
616
+ onChange={(e) => setDraft({ ...draft, status: e.target.value as FunnelStatus })}
617
+ >
618
+ {STATUSES.map((s) => (
619
+ <option key={s} value={s}>
620
+ {s}
621
+ </option>
622
+ ))}
623
+ </select>
624
+ <span className="flex-1" />
625
+ {canManage && (
626
+ <>
627
+ <button type="button" className={button} onClick={save} disabled={busy === 'save'}>
628
+ {busy === 'save' ? 'Saving…' : 'Save'}
629
+ </button>
630
+ <button type="button" className={primary} onClick={runNow} disabled={busy === 'run'}>
631
+ {busy === 'run' ? 'Starting…' : 'Run now'}
632
+ </button>
633
+ </>
634
+ )}
635
+ </div>
636
+
637
+ {activeRun && (
638
+ <p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
639
+ Started run <span data-testid="active-run">{activeRun.id}</span> — it is{' '}
640
+ {activeRun.status}. The Runs tab follows it to completion.
641
+ </p>
642
+ )}
643
+ {problem && (
644
+ <p className="text-sm text-red-600" role="alert">
645
+ {problem}
646
+ </p>
647
+ )}
648
+
649
+ <div role="tablist" className="flex gap-1 border-b border-border">
650
+ {tabs.map(([key, text]) => (
651
+ <button
652
+ key={key}
653
+ type="button"
654
+ role="tab"
655
+ aria-selected={tab === key}
656
+ className={`h-9 px-3 text-sm ${tab === key ? 'border-b-2 border-primary font-medium' : 'text-muted-foreground'}`}
657
+ onClick={() => setTab(key)}
658
+ >
659
+ {text}
660
+ </button>
661
+ ))}
662
+ </div>
663
+
664
+ {tab === 'stages' && (
665
+ <div data-testid="funnel-stages">
666
+ {vocabulary.error && (
667
+ <p className="mb-3 text-sm text-red-600" role="alert">
668
+ Couldn’t load the field vocabulary: {vocabulary.error}. Rules can’t be
669
+ edited until it loads — the same registry validates them on save.
670
+ </p>
671
+ )}
672
+ <FunnelStageBuilder
673
+ funnel={draft}
674
+ onUpdate={setDraft}
675
+ fieldRegistry={fieldRegistry}
676
+ />
677
+ </div>
678
+ )}
679
+
680
+ {tab === 'runs' && (
681
+ <FunnelRunHistory
682
+ funnelId={funnelId}
683
+ apiClient={client}
684
+ onViewResults={(run) => {
685
+ setResultsRun(run.id);
686
+ setTab('results');
687
+ }}
688
+ />
689
+ )}
690
+
691
+ {tab === 'results' &&
692
+ (resultsRun ? (
693
+ <RunResults client={client} funnel={draft} runId={resultsRun} pageSize={pageSize} />
694
+ ) : (
695
+ <p className="text-sm text-muted-foreground">
696
+ No run to show results for yet. Hit “Run now”, or pick a run from the Runs
697
+ tab.
698
+ </p>
699
+ ))}
700
+ </div>
701
+ );
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // The page
706
+ // ---------------------------------------------------------------------------
707
+
708
+ export function FunnelsPage({
709
+ client,
710
+ fieldsClient,
711
+ canManage = false,
712
+ pageSize = DEFAULT_PAGE_SIZE,
713
+ className = '',
714
+ }: FunnelsPageProps) {
715
+ const [selected, setSelected] = React.useState<string | null>(null);
716
+ const fields = fieldsClient ?? client;
717
+
718
+ return (
719
+ <div className={`space-y-5 ${className}`} data-testid="funnels-page">
720
+ {selected ? (
721
+ <FunnelDetail
722
+ client={client}
723
+ fieldsClient={fields}
724
+ funnelId={selected}
725
+ canManage={canManage}
726
+ pageSize={pageSize}
727
+ onBack={() => setSelected(null)}
728
+ />
729
+ ) : (
730
+ <FunnelList
731
+ client={client}
732
+ canManage={canManage}
733
+ pageSize={pageSize}
734
+ onOpen={(row) => setSelected(row.id)}
735
+ />
736
+ )}
737
+ </div>
738
+ );
739
+ }