fsanyoto commited on
Commit
64b8d76
Β·
verified Β·
1 Parent(s): 310336e

Prune files not in the deploy set (stale build context)

Browse files
api/pages_sales.py DELETED
@@ -1,276 +0,0 @@
1
- """pages_sales.py β€” the Sales page builder (W2-2). The FIRST Y1 builder, and the template.
2
-
3
- It reuses `royalimports_os/modules/sales.py` VERBATIM β€” the reconciled, validated data layer the
4
- Streamlit page already reads β€” so there is exactly one definition of every number and the API is a
5
- projection, not a second implementation. Nothing here computes revenue.
6
-
7
- WHAT A BUILDER IS, in three functions:
8
- metrics(team_id, granularity) the SCOPE-shaped data. Cached by `pages._cached_metrics`.
9
- β›” It cannot see the session, by design β€” that is what makes
10
- "nothing user-shaped is cached" a structural property rather
11
- than a rule somebody has to remember.
12
- blocks(metrics, team_id, …) the ordered Y1 blocks, built with `pages`' constructors.
13
- controls(granularity) the page's own toolbar (the BU picker is added by `pages`).
14
-
15
- β›” THE ONE LEAK THIS FILE HAD TO CLOSE. `sales.headline(team_id=…)` returns a `by_team` map that
16
- **always covers BOTH business units** β€” its own docstring says "by_team always shows both" β€” so a
17
- Royal-only request's headline carries Fisch revenue. `metrics()` therefore STRIPS `by_team` when a
18
- BU is pinned, at the source, before it is cached: the other BU's numbers never enter the cache entry
19
- for a scoped user, so a later edit that emits the block unconditionally renders nothing instead of
20
- leaking. Fail-closed by construction, not by review. Asserted in `verify_api.py`.
21
-
22
- ⚠ `by_rep` IS NOT THE STREAMLIT PAGE'S "By agent". `sales.by_rep` groups by Odoo `user_id` (the
23
- salesperson on the order); the Streamlit page shows `customers.by_dimension('agent')` (the
24
- CUSTOMER's assigned agent β€” a different number, see [[invoice-line-agent-commission]]). The split
25
- doc's brief names `by_rep`, so `by_rep` is what ships; the divergence is flagged in the S1 mailbox
26
- for the owner rather than resolved in code.
27
- """
28
- import sys as _sys
29
- import time
30
-
31
- import core.odoo as O
32
- import core.periods as P
33
- import modules.sales as sales
34
-
35
- import pages
36
-
37
- MODULE = "sales" # the REGISTRY key β€” what the grant wall gates on
38
- TITLE = "Sales"
39
-
40
- _GRANULARITIES = (("monthly", "Monthly"), ("weekly", "Weekly"))
41
-
42
-
43
- def resolve_granularity(period):
44
- """`?period=` β†’ the trend granularity.
45
-
46
- An unrecognised value is COERCED to the default rather than refused, and that asymmetry with
47
- `bu` is deliberate: `bu` is a SCOPE question, where a coerced parameter would silently show the
48
- wrong book, so it 403s. `period` is a DISPLAY question, and the envelope echoes the resolved
49
- value back in `controls[].value`, so a coerced parameter cannot look accepted.
50
- """
51
- return "weekly" if str(period or "").strip().lower() in ("weekly", "week") else "monthly"
52
-
53
-
54
- def controls(granularity):
55
- return [pages.choice_control("period", "Granularity", granularity, _GRANULARITIES)]
56
-
57
-
58
- def subtitle(team_id):
59
- return ("Confirmed orders only (Odoo state sale/done), house accounts excluded. Floral "
60
- "wholesale is seasonal, so every comparison is against the SAME period last year.")
61
-
62
-
63
- # ── the scope-shaped half ────────────────────────────────────────────────────────────────────────
64
- def metrics(team_id=None, granularity="monthly"):
65
- """Everything the blocks need, for ONE scope. Pure data β€” no session, no user, no formatting."""
66
- # Pin the recognition basis explicitly. It is thread-local and defaults to 'order', but a
67
- # worker thread reused from a container that once served 'invoice' would otherwise inherit a
68
- # different definition of revenue mid-page, and nothing in the payload would say so.
69
- O.set_doc_mode("order")
70
- t = P.today()
71
-
72
- head = sales.headline(t=t, team_id=team_id)
73
- if team_id is not None:
74
- # β›” See the module docstring: `by_team` always covers both BUs. Strip it BEFORE caching.
75
- head = dict(head, by_team={})
76
-
77
- if granularity == "weekly":
78
- trend = sales.weekly_trend(n_weeks=pages.MAX_BUCKETS, t=t, team_id=team_id)
79
- else:
80
- trend = sales.monthly_trend(n=pages.MAX_BUCKETS, t=t, team_id=team_id)
81
-
82
- return {
83
- "pulled_at": time.time(),
84
- "today": P._d(t),
85
- "scorecard": sales.period_scorecard(t=t, team_id=team_id),
86
- "headline": head,
87
- "trend": trend,
88
- # limit=1e9 so the TOTAL is knowable: the module slices after aggregating, so asking for
89
- # everything costs the same read and lets the block report "showing 25 of 812" instead of
90
- # capping silently. A bare `[:N]` with no total is the defect [[no-unverifiable-aggregates]]
91
- # exists to name.
92
- "by_rep": sales.by_rep(t=t, limit=10 ** 9, team_id=team_id),
93
- "top_customers": sales.top_customers(t=t, limit=10 ** 9, team_id=team_id),
94
- "validation": sales.validate(t=t, team_id=team_id),
95
- }
96
-
97
-
98
- # ── the blocks ───────────────────────────────────────────────────────────────────────────────────
99
- _LIST_LIMIT = 25
100
-
101
-
102
- def _delta_dir(v):
103
- if v is None:
104
- return None
105
- return "up" if v > 0 else "down" if v < 0 else "flat"
106
-
107
-
108
- def _scorecard_kpis(scorecard):
109
- """One card per period, every one of them drillable to its exact window."""
110
- items = []
111
- for r in scorecard:
112
- drill = pages.decomp_drill(r, "period", None, f"Sales Β· {r['label']}")
113
- if not r["revenue"]:
114
- # No orders yet in this window. Sending a βˆ’100% here is the alarming lie
115
- # `app.py:2846` guards against; send no number at all.
116
- items.append(pages.kpi(r["key"], r["label"], 0.0, "money",
117
- delta_label="no orders yet", delta_dir="off", drill=drill))
118
- continue
119
- items.append(pages.kpi(r["key"], r["label"], r["revenue"], "money",
120
- delta=r["yoy_pct"], delta_fmt="pct",
121
- delta_label="vs LY" if r["yoy_pct"] is not None else None,
122
- delta_dir=_delta_dir(r["yoy_pct"]), drill=drill))
123
- return pages.kpis("scorecard", items)
124
-
125
-
126
- def _ytd_context_kpis(scorecard, head):
127
- """The three YTD numbers the Streamlit page puts in a caption (`app.py:2858`). As KPI cards
128
- rather than prose, so each one carries the drill the caption could not."""
129
- ytd = next((r for r in scorecard if r["key"] == "ytd"), scorecard[-1] if scorecard else {})
130
- drill = pages.decomp_drill(ytd, "period", None, "Sales Β· Year to date")
131
- return pages.kpis("ytd_context", [
132
- pages.kpi("ytd_orders", "Orders (YTD)", head.get("ytd_orders") or 0, "int", drill=drill),
133
- pages.kpi("ytd_aov", "Average order value", head.get("aov") or 0.0, "money", drill=drill),
134
- pages.kpi("ytd_customers", "Active customers (YTD)", head.get("ytd_customers") or 0,
135
- "int", drill=drill),
136
- ])
137
-
138
-
139
- def _trend_block(trend, granularity, today):
140
- """The YoY trend: ONE x, TWO series, chronology declared by the server.
141
-
142
- See `pages.chart` for why this is `series[]` + `x_order[]` rather than `ChartSpec.splitBy` and
143
- a date-typed axis. `spec.y` is set to the FIRST series so a naive single-series
144
- `chartData(spec, …)` call still draws the primary bars instead of a "choose a number field"
145
- problem card.
146
- """
147
- label_key = "week" if granularity == "weekly" else "month"
148
- rows, x_order = [], []
149
- for r in trend:
150
- label = str(r.get(label_key) or "")
151
- revenue = r.get("revenue") or 0.0
152
- revenue_ly = r.get("revenue_ly") or 0.0
153
- # The in-progress period compares a PARTIAL window against a full one, so the client
154
- # fades it and drops its YoY (`app.py:2867-2875`). One rule for both granularities:
155
- # the window that CONTAINS today. ISO strings, so string order is date order.
156
- partial = bool(r.get("start") and r.get("end")
157
- and str(r["start"]) <= today <= str(r["end"]))
158
- rows.append({
159
- "period": label,
160
- "revenue": revenue,
161
- "revenue_ly": revenue_ly,
162
- "partial": partial,
163
- # The printed YoY % (the Streamlit chart's on-bar labels, `chart_group_yoy`). None for
164
- # the partial period β€” a partial-vs-full ratio is the number the rule exists to avoid.
165
- "yoy_pct": None if partial else P.yoy_pct(revenue, revenue_ly),
166
- "_drill": pages.decomp_drill(r, "period", None, f"Sales Β· {label}"),
167
- })
168
- x_order.append(label)
169
- unit = "Week of" if granularity == "weekly" else "Month"
170
- spec = {"id": "sales-trend", "kind": "bar", "x": "period", "y": "revenue", "agg": "sum",
171
- "title": "Revenue trend", "palette": "brand",
172
- "axis": {"x": {"label": unit}, "y": {"format": "currency"}}}
173
- fields = [{"key": "period", "label": unit, "type": "text", "source": "odoo"},
174
- {"key": "revenue", "label": "Revenue", "type": "currency", "source": "odoo"},
175
- {"key": "revenue_ly", "label": "Revenue LY", "type": "currency", "source": "odoo"}]
176
- series = [{"y": "revenue", "label": "This year"},
177
- {"y": "revenue_ly", "label": "Last year"}]
178
- return pages.chart("trend", spec, series, fields, rows, x_order=x_order,
179
- drill=pages.row_drill(), delta_key="yoy_pct")
180
-
181
-
182
- def _by_bu_block(head, scorecard):
183
- """YTD per Business Unit β€” CONSOLIDATED VIEWS ONLY.
184
-
185
- Omitted entirely when a BU is pinned, matching `app.py:2898`'s `if tid is None`. `metrics()` has
186
- already emptied `by_team` in that case, so this cannot accidentally render the other BU even if
187
- the guard here were removed β€” belt and braces on the one number that must never cross.
188
- """
189
- by_team = head.get("by_team") or {}
190
- if not by_team:
191
- return None
192
- ytd = next((r for r in scorecard if r["key"] == "ytd"), scorecard[-1] if scorecard else {})
193
- rows = []
194
- for tid in O.TEAM_IDS:
195
- name = O.TEAM_NAMES.get(tid)
196
- e = by_team.get(name)
197
- if not e:
198
- continue
199
- rows.append({
200
- "bu": name, "team_id": tid,
201
- "revenue": e.get("ytd") or 0.0, "revenue_ly": e.get("ytd_ly") or 0.0,
202
- "yoy_pct": P.yoy_pct(e.get("ytd") or 0.0, e.get("ytd_ly") or 0.0),
203
- "_drill": pages.decomp_drill(ytd, "brand", tid, f"Sales Β· {name} (YTD)"),
204
- })
205
- return pages.table(
206
- "by_bu",
207
- [pages.column("bu", "Business Unit"),
208
- pages.column("revenue", "YTD $", "money"),
209
- pages.column("revenue_ly", "LY $", "money"),
210
- pages.column("yoy_pct", "YoY", "pct")],
211
- rows, drill=pages.row_drill(), download={"filename": "sales_by_business_unit"},
212
- empty="No confirmed orders in either Business Unit this year.")
213
-
214
-
215
- def _by_rep_block(by_rep):
216
- rows, total = pages.truncate(by_rep, _LIST_LIMIT)
217
- return pages.table(
218
- "by_rep",
219
- [pages.column("rep", "Salesperson"),
220
- pages.column("revenue", "YTD $", "money"),
221
- pages.column("orders", "Orders", "int")],
222
- rows, total=total, drill=pages.entity_drill("rep", "uid", "rep"),
223
- download={"filename": "sales_by_rep"},
224
- empty="No confirmed orders are attributed to a salesperson in this scope.")
225
-
226
-
227
- def _top_customers_block(top_customers):
228
- rows, total = pages.truncate(top_customers, _LIST_LIMIT)
229
- return pages.table(
230
- "top_customers",
231
- [pages.column("customer", "Customer"),
232
- pages.column("revenue", "YTD $", "money"),
233
- pages.column("orders", "Orders", "int")],
234
- rows, total=total, drill=pages.entity_drill("customer", "pid", "customer"),
235
- download={"filename": "top_customers"},
236
- empty="No confirmed orders in this scope yet.")
237
-
238
-
239
- def blocks(metrics, team_id=None, granularity="monthly"):
240
- """The ORDERED blocks, mirroring `app.py:2833-2961`'s reading order.
241
-
242
- ⚠ The BU table is present only on a consolidated view, so the client must not assume a fixed
243
- block count or index β€” it renders `blocks` in order and skips a type it does not know.
244
- """
245
- sc = metrics["scorecard"]
246
- head = metrics["headline"]
247
- out = [
248
- _scorecard_kpis(sc),
249
- _ytd_context_kpis(sc, head),
250
- pages.section("trend_h", "Revenue trend",
251
- "This period versus the same period last year. Click any bar to decompose "
252
- "that period by customer and SKU. The in-progress period is marked partial "
253
- "and carries no YoY."),
254
- _trend_block(metrics["trend"], granularity, metrics["today"]),
255
- ]
256
- bu = _by_bu_block(head, sc)
257
- if bu is not None:
258
- out.append(pages.section("by_bu_h", "By Business Unit",
259
- "YTD revenue per Business Unit versus last year."))
260
- out.append(bu)
261
- out.append(pages.section("by_rep_h", "By salesperson",
262
- "YTD revenue per Odoo salesperson on the order."))
263
- out.append(_by_rep_block(metrics["by_rep"]))
264
- out.append(pages.section("top_customers_h", "Top customers",
265
- "Largest customers by YTD revenue."))
266
- out.append(_top_customers_block(metrics["top_customers"]))
267
- out.append(pages.validation(
268
- metrics["validation"],
269
- "Every headline number reconciles to an independent Odoo aggregate, and every "
270
- "decomposition sums back to it."))
271
- return out
272
-
273
-
274
- #: Self-registers on import, so adding a page is one file plus one import β€” `pages.builder_for`
275
- #: does the import and never needs a per-page branch.
276
- pages.register("sales", _sys.modules[__name__])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/automation/AutomationEditor.tsx DELETED
@@ -1,662 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // automation/AutomationEditor.tsx β€” one automation: what it does, when it runs,
3
- // and what it did last time.
4
- //
5
- // THE SHAPE OF THE SCRAPE FLOW IS THE POINT. You cannot map columns you have not
6
- // seen, so "Read the page" comes BEFORE the field map and the preview is real
7
- // rows off the real URL β€” not a schema the user is asked to describe from
8
- // memory. The server's `/automations/preview` never writes, so the read is free
9
- // to be taken as many times as it takes to get the mapping right.
10
- //
11
- // EVERY COUNT DRILLS TO ROWS. A run that says "412 updated" opens the 412
12
- // ([[no-unverifiable-aggregates]]) β€” a number with no way back to its records is
13
- // exactly what this codebase treats as a defect rather than a summary.
14
- // ---------------------------------------------------------------------------
15
- import { useCallback, useEffect, useMemo, useState } from "react";
16
-
17
- import type {
18
- Automation,
19
- AutomationKind,
20
- RunEntry,
21
- RunRows,
22
- SourcePreview,
23
- UserTable,
24
- } from "./automationApi";
25
- import {
26
- AutomationError,
27
- COUNT_LABELS,
28
- createAutomation,
29
- deleteAutomation,
30
- fieldKeyFor,
31
- listTables,
32
- patchAutomation,
33
- previewSource,
34
- runAutomation,
35
- runRows,
36
- } from "./automationApi";
37
-
38
- interface Props {
39
- automation: Automation | null;
40
- kinds: { key: AutomationKind; label: string }[];
41
- cronPresets: { cron: string; label: string }[];
42
- onSaved: (id?: string) => void | Promise<void>;
43
- onDeleted: () => void | Promise<void>;
44
- onCancel: () => void;
45
- }
46
-
47
- interface ColumnPlan {
48
- column: string;
49
- include: boolean;
50
- key: string;
51
- }
52
-
53
- const DEFAULT_CRON = "0 6 * * *";
54
-
55
- export default function AutomationEditor({
56
- automation,
57
- kinds,
58
- cronPresets,
59
- onSaved,
60
- onDeleted,
61
- onCancel,
62
- }: Props) {
63
- const isNew = !automation;
64
- const cfg = (automation?.config || {}) as Record<string, string | number>;
65
-
66
- const [name, setName] = useState(automation?.name || "");
67
- const [kind, setKind] = useState<AutomationKind>(automation?.kind || "scrape_db");
68
- const [cron, setCron] = useState(automation?.schedule?.cron || DEFAULT_CRON);
69
- const [enabled, setEnabled] = useState(!!automation?.schedule?.enabled);
70
- const [message, setMessage] = useState("");
71
- const [problem, setProblem] = useState("");
72
- const [saving, setSaving] = useState(false);
73
-
74
- // --- scrape_db half
75
- const [url, setUrl] = useState(String(cfg.url || ""));
76
- const [extract, setExtract] = useState(String(cfg.extract || "table"));
77
- const [tableIndex, setTableIndex] = useState(Number(cfg.tableIndex || 0));
78
- const [targetLabel, setTargetLabel] = useState(String(cfg.targetLabel || ""));
79
- const [preview, setPreview] = useState<SourcePreview | null>(null);
80
- const [reading, setReading] = useState(false);
81
- const [plan, setPlan] = useState<ColumnPlan[]>(() =>
82
- Object.entries((automation?.config as { fieldMap?: Record<string, string> })?.fieldMap || {})
83
- .map(([column, key]) => ({ column, include: true, key }))
84
- );
85
- const [keyField, setKeyField] = useState(String(cfg.keyField || ""));
86
-
87
- // --- field_instagram half
88
- const [tables, setTables] = useState<UserTable[]>([]);
89
- const [targetTable, setTargetTable] = useState(String(cfg.targetTable || ""));
90
- const [fieldKey, setFieldKey] = useState(String(cfg.fieldKey || ""));
91
- const [urlField, setUrlField] = useState(String(cfg.urlField || ""));
92
- const [maxPosts, setMaxPosts] = useState(Number(cfg.maxPosts || 24));
93
-
94
- // --- run history drill
95
- const [drill, setDrill] = useState<RunRows | null>(null);
96
- const [drillFor, setDrillFor] = useState("");
97
-
98
- useEffect(() => {
99
- const ac = new AbortController();
100
- listTables(ac.signal)
101
- .then((r) => setTables(r.tables))
102
- .catch(() => setTables([]));
103
- return () => ac.abort();
104
- }, []);
105
-
106
- const table = tables.find((t) => t.key === targetTable) || null;
107
-
108
- const readPage = useCallback(async () => {
109
- setReading(true);
110
- setProblem("");
111
- try {
112
- const p = await previewSource(url, extract, tableIndex);
113
- setPreview(p);
114
- if (!p.ok) {
115
- setProblem(p.note || `That page answered ${p.status}.`);
116
- return;
117
- }
118
- // Seed the plan from what the page ACTUALLY offers, keeping any mapping the user
119
- // already made for a column that is still there β€” re-reading a page must not throw
120
- // away the work of mapping it.
121
- setPlan((prev) => {
122
- const had = new Map(prev.map((c) => [c.column, c]));
123
- return p.columns.map((column) => {
124
- const before = had.get(column);
125
- return {
126
- column,
127
- include: before ? before.include : true,
128
- key: before ? before.key : fieldKeyFor(column),
129
- };
130
- });
131
- });
132
- setKeyField((k) =>
133
- k && p.columns.some((c) => fieldKeyFor(c) === k) ? k : fieldKeyFor(p.columns[0] || "")
134
- );
135
- if (!targetLabel) setTargetLabel(p.title || "Scraped table");
136
- setMessage(
137
- `Read ${p.rowCount} rows and ${p.columns.length} columns` +
138
- (p.tableCount && p.tableCount > 1 ? ` (of ${p.tableCount} tables on the page)` : "")
139
- );
140
- } catch (e) {
141
- setProblem(
142
- e instanceof AutomationError ? e.message : "That page could not be read."
143
- );
144
- } finally {
145
- setReading(false);
146
- }
147
- }, [url, extract, tableIndex, targetLabel]);
148
-
149
- const buildConfig = (): Record<string, unknown> => {
150
- if (kind === "scrape_db") {
151
- const fieldMap: Record<string, string> = {};
152
- for (const c of plan) if (c.include && c.key) fieldMap[c.column] = c.key;
153
- return {
154
- url,
155
- extract,
156
- tableIndex,
157
- fieldMap,
158
- keyField,
159
- targetTable: String(cfg.targetTable || ""),
160
- targetLabel,
161
- };
162
- }
163
- return { targetTable, fieldKey, urlField, maxPosts };
164
- };
165
-
166
- const save = async () => {
167
- setSaving(true);
168
- setProblem("");
169
- setMessage("");
170
- const body = {
171
- name,
172
- kind,
173
- config: buildConfig(),
174
- schedule: { cron, enabled },
175
- };
176
- try {
177
- const res = automation
178
- ? await patchAutomation(automation.id, body)
179
- : await createAutomation(body);
180
- setMessage("Saved.");
181
- await onSaved(res.automation.id);
182
- } catch (e) {
183
- setProblem(e instanceof AutomationError ? e.message : "That could not be saved.");
184
- } finally {
185
- setSaving(false);
186
- }
187
- };
188
-
189
- const start = async () => {
190
- if (!automation) return;
191
- setProblem("");
192
- try {
193
- await runAutomation(automation.id);
194
- setMessage("Started. The rail's status dot follows it.");
195
- await onSaved(automation.id);
196
- } catch (e) {
197
- setProblem(e instanceof AutomationError ? e.message : "It did not start.");
198
- }
199
- };
200
-
201
- const remove = async () => {
202
- if (!automation) return;
203
- setProblem("");
204
- try {
205
- await deleteAutomation(automation.id);
206
- await onDeleted();
207
- } catch (e) {
208
- setProblem(e instanceof AutomationError ? e.message : "It was not deleted.");
209
- }
210
- };
211
-
212
- const openDrill = async (entry: RunEntry) => {
213
- if (!automation) return;
214
- if (drillFor === entry.ts) {
215
- setDrill(null);
216
- setDrillFor("");
217
- return;
218
- }
219
- try {
220
- const rows = await runRows(automation.id);
221
- setDrill(rows);
222
- setDrillFor(entry.ts);
223
- } catch (e) {
224
- setProblem(e instanceof AutomationError ? e.message : "Those rows could not be read.");
225
- }
226
- };
227
-
228
- const presetMatch = useMemo(
229
- () => cronPresets.find((p) => p.cron === cron)?.cron || "",
230
- [cronPresets, cron]
231
- );
232
-
233
- const canSave =
234
- !!name.trim() &&
235
- (kind === "scrape_db"
236
- ? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
237
- : !!targetTable && !!fieldKey);
238
-
239
- return (
240
- <div className="auto-editor">
241
- <header className="auto-head">
242
- <div className="auto-head-title">
243
- <input
244
- className="auto-name"
245
- value={name}
246
- placeholder="Name this automation"
247
- aria-label="Automation name"
248
- onChange={(e) => setName(e.target.value)}
249
- />
250
- {automation ? (
251
- <span className={`auto-chip is-${automation.running ? "running"
252
- : automation.status?.state || "idle"}`}>
253
- {automation.running ? "Running" : automation.status?.state || "idle"}
254
- </span>
255
- ) : (
256
- <span className="auto-chip is-idle">New</span>
257
- )}
258
- </div>
259
- <div className="auto-head-actions">
260
- {automation ? (
261
- <button
262
- type="button"
263
- className="auto-btn"
264
- disabled={!!automation.running}
265
- onClick={() => void start()}
266
- >
267
- {automation.running ? "Running…" : "Run now"}
268
- </button>
269
- ) : null}
270
- <button
271
- type="button"
272
- className="auto-btn is-primary"
273
- disabled={!canSave || saving}
274
- onClick={() => void save()}
275
- >
276
- {saving ? "Saving…" : automation ? "Save" : "Create"}
277
- </button>
278
- {automation ? (
279
- <button type="button" className="auto-btn is-danger" onClick={() => void remove()}>
280
- Delete
281
- </button>
282
- ) : (
283
- <button type="button" className="auto-btn" onClick={onCancel}>
284
- Cancel
285
- </button>
286
- )}
287
- </div>
288
- </header>
289
-
290
- {problem ? (
291
- <div className="auto-banner is-error" role="alert">
292
- {problem}
293
- </div>
294
- ) : null}
295
- {message ? (
296
- <div className="auto-banner is-ok" role="status">
297
- {message}
298
- </div>
299
- ) : null}
300
-
301
- <div className="auto-body">
302
- <section className="auto-card">
303
- <h2>What it does</h2>
304
- {isNew ? (
305
- <div className="auto-kinds">
306
- {kinds.map((k) => (
307
- <label key={k.key} className={"auto-kind" + (kind === k.key ? " is-on" : "")}>
308
- <input
309
- type="radio"
310
- name="auto-kind"
311
- checked={kind === k.key}
312
- onChange={() => setKind(k.key)}
313
- />
314
- <span>{k.label}</span>
315
- </label>
316
- ))}
317
- </div>
318
- ) : (
319
- <p className="auto-note">
320
- {kinds.find((k) => k.key === kind)?.label || kind} β€” the kind is fixed once an
321
- automation exists, because its configuration and the database it fills are
322
- shaped by it.
323
- </p>
324
- )}
325
-
326
- {kind === "scrape_db" ? (
327
- <>
328
- <div className="auto-field">
329
- <label htmlFor="auto-url">Page URL</label>
330
- <div className="auto-row-inline">
331
- <input
332
- id="auto-url"
333
- className="auto-input"
334
- value={url}
335
- placeholder="https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
336
- onChange={(e) => setUrl(e.target.value)}
337
- />
338
- <button
339
- type="button"
340
- className="auto-btn"
341
- disabled={!url.trim() || reading}
342
- onClick={() => void readPage()}
343
- >
344
- {reading ? "Reading…" : "Read the page"}
345
- </button>
346
- </div>
347
- <p className="auto-hint">
348
- Public web pages only. Private, loopback and link-local addresses are
349
- refused β€” including a public URL that redirects to one.
350
- </p>
351
- </div>
352
-
353
- <div className="auto-field-row">
354
- <div className="auto-field">
355
- <label htmlFor="auto-extract">Read</label>
356
- <select
357
- id="auto-extract"
358
- className="auto-input"
359
- value={extract}
360
- onChange={(e) => setExtract(e.target.value)}
361
- >
362
- <option value="table">An HTML table</option>
363
- <option value="jsonld">Structured data (JSON-LD)</option>
364
- </select>
365
- </div>
366
- {extract === "table" ? (
367
- <div className="auto-field">
368
- <label htmlFor="auto-tidx">Which table</label>
369
- <input
370
- id="auto-tidx"
371
- className="auto-input"
372
- type="number"
373
- min={0}
374
- value={tableIndex}
375
- onChange={(e) => setTableIndex(Number(e.target.value) || 0)}
376
- />
377
- </div>
378
- ) : null}
379
- <div className="auto-field">
380
- <label htmlFor="auto-tlabel">Database name</label>
381
- <input
382
- id="auto-tlabel"
383
- className="auto-input"
384
- value={targetLabel}
385
- placeholder="S&P 500 companies"
386
- onChange={(e) => setTargetLabel(e.target.value)}
387
- />
388
- </div>
389
- </div>
390
-
391
- {plan.length ? (
392
- <div className="auto-map">
393
- <h3>Columns</h3>
394
- <p className="auto-hint">
395
- Pick the key β€” the column that identifies a row. On every re-run, a row
396
- whose key already exists is updated; a new key is inserted; a key that
397
- has stopped appearing is counted and kept.
398
- </p>
399
- <table className="auto-table">
400
- <thead>
401
- <tr>
402
- <th>Use</th>
403
- <th>Source column</th>
404
- <th>Field key</th>
405
- <th>Key</th>
406
- {preview?.sample?.length ? <th>First value</th> : null}
407
- </tr>
408
- </thead>
409
- <tbody>
410
- {plan.map((c, i) => (
411
- <tr key={c.column}>
412
- <td>
413
- <input
414
- type="checkbox"
415
- checked={c.include}
416
- aria-label={`Include ${c.column}`}
417
- onChange={(e) =>
418
- setPlan((p) =>
419
- p.map((x, j) =>
420
- j === i ? { ...x, include: e.target.checked } : x
421
- )
422
- )
423
- }
424
- />
425
- </td>
426
- <td className="auto-cell-name">{c.column}</td>
427
- <td>
428
- <input
429
- className="auto-input is-small"
430
- value={c.key}
431
- aria-label={`Field key for ${c.column}`}
432
- onChange={(e) =>
433
- setPlan((p) =>
434
- p.map((x, j) =>
435
- j === i ? { ...x, key: fieldKeyFor(e.target.value) } : x
436
- )
437
- )
438
- }
439
- />
440
- </td>
441
- <td>
442
- <input
443
- type="radio"
444
- name="auto-keyfield"
445
- checked={keyField === c.key && c.include}
446
- disabled={!c.include}
447
- aria-label={`Use ${c.column} as the key`}
448
- onChange={() => setKeyField(c.key)}
449
- />
450
- </td>
451
- {preview?.sample?.length ? (
452
- <td className="auto-cell-sample">
453
- {String(preview.sample[0]?.[c.column] ?? "")}
454
- </td>
455
- ) : null}
456
- </tr>
457
- ))}
458
- </tbody>
459
- </table>
460
- </div>
461
- ) : (
462
- <p className="auto-note">
463
- Read the page to see what columns it offers.
464
- </p>
465
- )}
466
- </>
467
- ) : (
468
- <>
469
- <div className="auto-field-row">
470
- <div className="auto-field">
471
- <label htmlFor="auto-table">Database</label>
472
- <select
473
- id="auto-table"
474
- className="auto-input"
475
- value={targetTable}
476
- onChange={(e) => {
477
- setTargetTable(e.target.value);
478
- setFieldKey("");
479
- setUrlField("");
480
- }}
481
- >
482
- <option value="">Choose a database…</option>
483
- {tables.map((t) => (
484
- <option key={t.key} value={t.key}>
485
- {t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
486
- </option>
487
- ))}
488
- </select>
489
- </div>
490
- <div className="auto-field">
491
- <label htmlFor="auto-fieldkey">Automation column</label>
492
- <select
493
- id="auto-fieldkey"
494
- className="auto-input"
495
- value={fieldKey}
496
- onChange={(e) => setFieldKey(e.target.value)}
497
- >
498
- <option value="">Choose a column…</option>
499
- {(table?.fields || [])
500
- .filter((f) => f.type === "automation")
501
- .map((f) => (
502
- <option key={f.key} value={f.key}>
503
- {f.label}
504
- </option>
505
- ))}
506
- </select>
507
- </div>
508
- <div className="auto-field">
509
- <label htmlFor="auto-urlfield">Profile URL column</label>
510
- <select
511
- id="auto-urlfield"
512
- className="auto-input"
513
- value={urlField}
514
- onChange={(e) => setUrlField(e.target.value)}
515
- >
516
- <option value="">The column the field is bound to</option>
517
- {(table?.fields || []).map((f) => (
518
- <option key={f.key} value={f.key}>
519
- {f.label}
520
- </option>
521
- ))}
522
- </select>
523
- </div>
524
- <div className="auto-field">
525
- <label htmlFor="auto-maxposts">Posts per pull</label>
526
- <input
527
- id="auto-maxposts"
528
- className="auto-input"
529
- type="number"
530
- min={1}
531
- max={200}
532
- value={maxPosts}
533
- onChange={(e) => setMaxPosts(Number(e.target.value) || 24)}
534
- />
535
- </div>
536
- </div>
537
- <p className="auto-hint">
538
- Anonymous public endpoints only β€” no login and no credentials. Instagram
539
- blocks datacenter addresses and has closed most of its anonymous surface,
540
- so a pull reports what it actually got: <strong>ok</strong> (profile and
541
- posts), <strong>partial</strong> (profile only), or <strong>blocked</strong>.
542
- It never reports success on nothing.
543
- </p>
544
- {table && !(table.fields || []).some((f) => f.type === "automation") ? (
545
- <p className="auto-note">
546
- That database has no automation column yet. Add one from the grid&rsquo;s
547
- column menu, then choose it here.
548
- </p>
549
- ) : null}
550
- </>
551
- )}
552
- </section>
553
-
554
- <section className="auto-card">
555
- <h2>When it runs</h2>
556
- <div className="auto-field-row">
557
- <div className="auto-field">
558
- <label htmlFor="auto-preset">Schedule</label>
559
- <select
560
- id="auto-preset"
561
- className="auto-input"
562
- value={presetMatch}
563
- onChange={(e) => e.target.value && setCron(e.target.value)}
564
- >
565
- <option value="">Custom…</option>
566
- {cronPresets.map((p) => (
567
- <option key={p.cron} value={p.cron}>
568
- {p.label}
569
- </option>
570
- ))}
571
- </select>
572
- </div>
573
- <div className="auto-field">
574
- <label htmlFor="auto-cron">Cron (minute hour day month weekday)</label>
575
- <input
576
- id="auto-cron"
577
- className="auto-input is-mono"
578
- value={cron}
579
- onChange={(e) => setCron(e.target.value)}
580
- />
581
- </div>
582
- <label className="auto-check">
583
- <input
584
- type="checkbox"
585
- checked={enabled}
586
- onChange={(e) => setEnabled(e.target.checked)}
587
- />
588
- <span>Run on this schedule</span>
589
- </label>
590
- </div>
591
- <p className="auto-hint">
592
- {automation?.nextRunAt
593
- ? `Next run ${automation.nextRunAt}.`
594
- : enabled
595
- ? "Saved schedules start from the moment they are enabled β€” enabling a daily job after today's time does not fire it immediately."
596
- : "Disabled. It still runs when you press Run now."}
597
- </p>
598
- </section>
599
-
600
- <section className="auto-card">
601
- <h2>Run history</h2>
602
- {!automation?.runs?.length ? (
603
- <p className="auto-note">It has not run yet.</p>
604
- ) : (
605
- <ol className="auto-runs">
606
- {automation.runs.map((r) => (
607
- <li key={r.ts} className={"auto-run is-" + (r.ok ? "ok" : "error")}>
608
- <button
609
- type="button"
610
- className="auto-run-head"
611
- onClick={() => void openDrill(r)}
612
- aria-expanded={drillFor === r.ts}
613
- >
614
- <span className="auto-run-ts">{r.ts.replace("T", " ")}</span>
615
- <span className="auto-run-summary">{r.summary}</span>
616
- </button>
617
- <div className="auto-counts">
618
- {COUNT_LABELS.filter(([k]) => typeof r.counts?.[k] === "number").map(
619
- ([k, label]) => (
620
- <span key={k} className="auto-count">
621
- <b>{r.counts[k]}</b> {label}
622
- </span>
623
- )
624
- )}
625
- </div>
626
- {drillFor === r.ts && drill ? (
627
- <div className="auto-drill">
628
- <p className="auto-hint">
629
- The rows this run touched in {drill.label}
630
- {drill.truncated ? " (first 200)" : ""}.
631
- </p>
632
- <div className="auto-drill-scroll">
633
- <table className="auto-table">
634
- <thead>
635
- <tr>
636
- {drill.fields.map((f) => (
637
- <th key={f.key}>{f.label}</th>
638
- ))}
639
- </tr>
640
- </thead>
641
- <tbody>
642
- {drill.rows.slice(0, 50).map((row) => (
643
- <tr key={String(row.id)}>
644
- {drill.fields.map((f) => (
645
- <td key={f.key}>{String(row[f.key] ?? "")}</td>
646
- ))}
647
- </tr>
648
- ))}
649
- </tbody>
650
- </table>
651
- </div>
652
- </div>
653
- ) : null}
654
- </li>
655
- ))}
656
- </ol>
657
- )}
658
- </section>
659
- </div>
660
- </div>
661
- );
662
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/customer-grid/CohortSidebar.tsx DELETED
@@ -1,585 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / CohortSidebar.tsx
3
- // The Cohort page's left panel (wave-2 item 2c, 2026-07-27): the COHORT LIST
4
- // replaces the Views rail. A cohort is a fixed, hand-curated subset β€” the rail
5
- // is a switcher over `workspace.lists` (name Β· member count) plus each row's
6
- // lifecycle menu: the host page's Manage popover retired with wave 2, so
7
- // RENAME and DELETE live here (the contract's cohort_rename / cohort_delete
8
- // events; capability leaves a UI only with a replacement). Creation stays on
9
- // the Customer page ("Add to cohort"); membership edits happen on the grid
10
- // itself (the selection bar's "Remove from cohort", the toolbar's "+ Add
11
- // customers").
12
- //
13
- // The member count is the cohort's SERVABLE size β€” its pids within the
14
- // caller's pool. When members fall outside the 24-month pool the host says so
15
- // via `missing`, and the active row DISCLOSES it: a cohort silently smaller
16
- // than its true membership is a forbidden silent truncation (rule 8b).
17
- // ---------------------------------------------------------------------------
18
-
19
- import { useState } from "react";
20
- import type { KeyboardEvent as ReactKeyboardEvent } from "react";
21
- import { AnchoredOverlay } from "./OverlaySurface";
22
- import { EXPORT_FORMATS, EXPORT_LABELS } from "./export";
23
- import type { ExportFormat } from "./export";
24
- import type { GridFolder } from "./types";
25
- import { groupByFolder } from "./folders";
26
-
27
- export interface CohortSidebarProps {
28
- lists: { id: string; name: string; pids?: number[]; missing?: number; folderId?: string | null }[];
29
- /**
30
- * Wave-8 I11c (contract C4) β€” folders, the cohort rail's half. Same model as the
31
- * Views rail (folders.ts is surface-agnostic); all optional, so a caller that
32
- * wires none of it renders the pre-wave-8 flat list.
33
- *
34
- * The bulk "Add to cohort" here is a plain SET UNION of the contained cohorts'
35
- * member pids β€” no engine run, and therefore none of the Views rail's two
36
- * refusals: a cohort already IS a fixed pid set, so nothing about it can be
37
- * pending or unanswerable.
38
- */
39
- folders?: GridFolder[];
40
- folderIdOf?: (cohortId: string) => string | null;
41
- onFolderCreate?: (name: string) => void;
42
- onFolderRename?: (folderId: string, name: string) => void;
43
- onFolderDelete?: (folderId: string) => void;
44
- onFolderDuplicate?: (folderId: string) => void;
45
- onItemMove?: (cohortId: string, folderId: string | null) => void;
46
- /** Add the UNION of this folder's cohorts to `cohortId` (empty = create `name`). */
47
- onFolderAddToList?: (folderId: string, cohortId: string, name: string) => void;
48
- /** The tenant's today, seeding a new cohort's name. Never the browser clock. */
49
- today?: string;
50
- activeCohortId: string | null;
51
- onSelect: (id: string) => void;
52
- /** Emits `cohort_rename` β€” the host validates ownership and re-renders with the new name. */
53
- onRename: (id: string, name: string) => void;
54
- /** Emits `cohort_delete`. Destructive, so the menu arms first (same pattern as field
55
- * delete): first click reads "Delete permanently?", the second fires. */
56
- onDelete: (id: string) => void;
57
- /** Wave-7 item W2 (contract C2) β€” export THIS cohort's current matched rows
58
- * (its saved view's filters + sorts over the cohort's fixed membership). */
59
- onExport?: (cohortId: string, format: ExportFormat) => void;
60
- }
61
-
62
- export default function CohortSidebar({
63
- lists,
64
- activeCohortId,
65
- onSelect,
66
- folders,
67
- folderIdOf,
68
- onFolderCreate,
69
- onFolderRename,
70
- onFolderDelete,
71
- onFolderDuplicate,
72
- onItemMove,
73
- onFolderAddToList,
74
- today,
75
- onRename,
76
- onDelete,
77
- onExport,
78
- }: CohortSidebarProps) {
79
- const [menu, setMenu] = useState<{
80
- cohortId: string;
81
- anchor: HTMLButtonElement;
82
- } | null>(null);
83
- const [renamingId, setRenamingId] = useState<string | null>(null);
84
- const [confirmDelete, setConfirmDelete] = useState(false);
85
- /** W2 β€” the Export format pane, anchored where the "…" menu was. */
86
- const [exportFor, setExportFor] = useState<{
87
- cohortId: string;
88
- anchor: HTMLButtonElement;
89
- } | null>(null);
90
- const exportCohort = exportFor
91
- ? lists.find((l) => l.id === exportFor.cohortId)
92
- : undefined;
93
- const menuCohort = menu ? lists.find((l) => l.id === menu.cohortId) : undefined;
94
- // I11c - folder UI state. Collapse is LOCAL (C4), as on the Views rail.
95
- const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
96
- const [folderMenu, setFolderMenu] = useState<{ id: string; anchor: HTMLElement } | null>(null);
97
- const [renamingFolder, setRenamingFolder] = useState<string | null>(null);
98
- const [creatingFolder, setCreatingFolder] = useState(false);
99
- const [confirmFolderDelete, setConfirmFolderDelete] = useState<string | null>(null);
100
- const [addTarget, setAddTarget] = useState<string | null>(null);
101
- /** The element the bulk-add pane hangs off. Anchoring to document.body
102
- * put it at the bottom of the DOCUMENT rather than beside the folder β€”
103
- * painted, and effectively unreachable ([[ui-invisible-to-assertions]]).
104
- * Captured from the folder menu before that menu is dismissed. */
105
- const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
106
- const [addName, setAddName] = useState("");
107
- const [dropTarget, setDropTarget] = useState<string | null>(null);
108
- const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
109
- const groups = groupByFolder(lists, foldersOn ? (folders as GridFolder[]) : [], (l) =>
110
- folderIdOf ? folderIdOf(l.id) : null
111
- );
112
- const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
113
- /** C4 - a cohort folder's union is a plain SET UNION of member pids. No engine
114
- * run, so none of the Views rail's two refusals apply: a cohort already IS a
115
- * fixed pid set, so nothing about it can be pending or unanswerable. */
116
- const folderUnion = (folderId: string): number[] => {
117
- const pids = new Set<number>();
118
- for (const l of lists) {
119
- if (!folderIdOf || folderIdOf(l.id) !== folderId) continue;
120
- for (const pid of l.pids ?? []) pids.add(pid);
121
- }
122
- return [...pids];
123
- };
124
- const addUnion = addTarget ? folderUnion(addTarget) : [];
125
- const addCount = addTarget
126
- ? lists.filter((l) => folderIdOf && folderIdOf(l.id) === addTarget).length
127
- : 0;
128
- const dropHandlers = (folderId: string | null) =>
129
- foldersOn
130
- ? {
131
- onDragOver: (e: React.DragEvent) => {
132
- e.preventDefault();
133
- setDropTarget(folderId ?? "__root__");
134
- },
135
- onDragLeave: () => setDropTarget(null),
136
- onDrop: (e: React.DragEvent) => {
137
- e.preventDefault();
138
- setDropTarget(null);
139
- const id = e.dataTransfer.getData("text/plain");
140
- if (id) onItemMove?.(id, folderId);
141
- },
142
- }
143
- : {};
144
-
145
- const closeMenu = () => {
146
- setMenu(null);
147
- setConfirmDelete(false);
148
- };
149
-
150
- const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
151
- if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
152
- const items = [
153
- ...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
154
- ];
155
- if (!items.length) return;
156
- event.preventDefault();
157
- const current = items.indexOf(document.activeElement as HTMLButtonElement);
158
- const next =
159
- event.key === "Home"
160
- ? 0
161
- : event.key === "End"
162
- ? items.length - 1
163
- : event.key === "ArrowDown"
164
- ? (current + 1) % items.length
165
- : (current - 1 + items.length) % items.length;
166
- items[next]?.focus();
167
- };
168
-
169
- return (
170
- <aside className="cg-views cg-cohorts" aria-label="Cohorts">
171
- <div className="cg-views-head">
172
- <div>
173
- <div className="cg-views-title">Cohorts</div>
174
- <div className="cg-views-save">
175
- {lists.length === 1 ? "1 cohort" : `${lists.length.toLocaleString()} cohorts`}
176
- </div>
177
- </div>
178
- </div>
179
- {foldersOn && onFolderCreate && (
180
- <div className="cg-fold-new">
181
- {creatingFolder ? (
182
- <input
183
- className="cg-input"
184
- autoFocus
185
- placeholder="Folder name"
186
- onBlur={(e) => {
187
- const v = e.target.value.trim();
188
- if (v) onFolderCreate(v);
189
- setCreatingFolder(false);
190
- }}
191
- onKeyDown={(e) => {
192
- if (e.key === "Enter") e.currentTarget.blur();
193
- if (e.key === "Escape") setCreatingFolder(false);
194
- }}
195
- />
196
- ) : (
197
- <button type="button" className="cg-link-btn" onClick={() => setCreatingFolder(true)}>
198
- + New folder
199
- </button>
200
- )}
201
- </div>
202
- )}
203
- <div className="cg-view-list">
204
- {groups.map((group) => {
205
- const gid = group.folder?.id ?? null;
206
- const shut = gid != null && collapsed.has(gid);
207
- return (
208
- <div
209
- key={gid ?? "__root__"}
210
- className={
211
- (gid === null ? "cg-fold-root" : "cg-fold") +
212
- (dropTarget === (gid ?? "__root__") ? " is-drop" : "")
213
- }
214
- {...dropHandlers(gid)}
215
- >
216
- {group.folder && (
217
- <div className="cg-fold-head">
218
- <button
219
- type="button"
220
- className="cg-fold-toggle"
221
- aria-expanded={!shut}
222
- onClick={() =>
223
- setCollapsed((prev) => {
224
- const next = new Set(prev);
225
- if (next.has(group.folder!.id)) next.delete(group.folder!.id);
226
- else next.add(group.folder!.id);
227
- return next;
228
- })
229
- }
230
- >
231
- <span className={"cg-fold-chev" + (shut ? " is-shut" : "")} aria-hidden>
232
- β–Ύ
233
- </span>
234
- {renamingFolder === group.folder.id ? (
235
- <input
236
- className="cg-input cg-fold-rename"
237
- autoFocus
238
- defaultValue={group.folder.name}
239
- onClick={(e) => e.stopPropagation()}
240
- onBlur={(e) => {
241
- const v = e.target.value.trim();
242
- if (v && v !== group.folder!.name) onFolderRename?.(group.folder!.id, v);
243
- setRenamingFolder(null);
244
- }}
245
- onKeyDown={(e) => {
246
- if (e.key === "Enter") e.currentTarget.blur();
247
- if (e.key === "Escape") setRenamingFolder(null);
248
- }}
249
- />
250
- ) : (
251
- <span className="cg-fold-name">{group.folder.name}</span>
252
- )}
253
- <span className="cg-fold-count">{group.items.length}</span>
254
- </button>
255
- <button
256
- type="button"
257
- className="cg-view-more"
258
- aria-label={`Actions for folder ${group.folder.name}`}
259
- aria-haspopup="menu"
260
- onClick={(e) =>
261
- setFolderMenu((cur) =>
262
- cur?.id === group.folder!.id
263
- ? null
264
- : { id: group.folder!.id, anchor: e.currentTarget }
265
- )
266
- }
267
- >
268
- Β·Β·Β·
269
- </button>
270
- </div>
271
- )}
272
- {!shut &&
273
- group.items.map((l) => {
274
- const active = l.id === activeCohortId;
275
- const renaming = renamingId === l.id;
276
- return (
277
- <div
278
- key={l.id}
279
- draggable={foldersOn && !renaming}
280
- onDragStart={(e) => e.dataTransfer.setData("text/plain", l.id)}
281
- >
282
- <div className={"cg-view-row" + (active ? " is-active" : "")}>
283
- {renaming ? (
284
- <input
285
- className="cg-view-rename cg-input"
286
- autoFocus
287
- defaultValue={l.name}
288
- aria-label={`Rename ${l.name}`}
289
- onBlur={(event) => {
290
- const next = event.target.value.trim();
291
- if (next && next !== l.name) onRename(l.id, next);
292
- setRenamingId(null);
293
- }}
294
- onKeyDown={(event) => {
295
- if (event.key === "Enter") event.currentTarget.blur();
296
- if (event.key === "Escape") setRenamingId(null);
297
- }}
298
- />
299
- ) : (
300
- <button
301
- type="button"
302
- className="cg-view-main"
303
- onClick={() => onSelect(l.id)}
304
- title={l.name}
305
- aria-current={active ? "true" : undefined}
306
- >
307
- <span className="cg-view-dot cg-view-dot--list" aria-hidden />
308
- <span className="cg-cohort-name">{l.name}</span>
309
- <span className="cg-cohort-count">
310
- {(l.pids?.length ?? 0).toLocaleString()}
311
- </span>
312
- </button>
313
- )}
314
- <button
315
- type="button"
316
- className="cg-view-more"
317
- aria-label={`Actions for ${l.name}`}
318
- aria-haspopup="menu"
319
- aria-expanded={menu?.cohortId === l.id}
320
- onClick={(event) => {
321
- const anchor = event.currentTarget;
322
- setConfirmDelete(false);
323
- setMenu((current) =>
324
- current?.cohortId === l.id ? null : { cohortId: l.id, anchor }
325
- );
326
- }}
327
- >
328
- Β·Β·Β·
329
- </button>
330
- </div>
331
- {/* Rule 8b: the ACTIVE cohort's members outside the caller's 24-month pool are
332
- not in the table β€” say so where the count is, not nowhere. */}
333
- {active && (l.missing ?? 0) > 0 && (
334
- <div className="cg-cohort-missing">
335
- {l.missing!.toLocaleString()} not active in the last 24 months, not shown
336
- </div>
337
- )}
338
- </div>
339
- );
340
- })}
341
- {group.folder && !shut && group.items.length === 0 && (
342
- <div className="cg-fold-empty">Empty β€” drag a cohort here.</div>
343
- )}
344
- </div>
345
- );
346
- })}
347
- {/* Zero cohorts is the HOST page's near-empty state (it says so before the grid);
348
- this line is only the in-frame backstop, and admin having none is CORRECT. */}
349
- {lists.length === 0 && (
350
- <div className="cg-cohort-empty">
351
- No cohorts yet. Build one from the Customer page: filter or check customers,
352
- then Add to cohort.
353
- </div>
354
- )}
355
- </div>
356
-
357
- {folderMenu && folderMenuF && (
358
- <AnchoredOverlay
359
- anchor={folderMenu.anchor}
360
- className="cg-view-menu"
361
- placement="bottom-end"
362
- role="menu"
363
- ariaLabel={`Actions for folder ${folderMenuF.name}`}
364
- onDismiss={() => {
365
- setFolderMenu(null);
366
- setConfirmFolderDelete(null);
367
- }}
368
- dataKind="folder-menu"
369
- >
370
- <button
371
- type="button"
372
- role="menuitem"
373
- className="cg-menu-item"
374
- onClick={() => {
375
- setRenamingFolder(folderMenuF.id);
376
- setFolderMenu(null);
377
- }}
378
- >
379
- Rename
380
- </button>
381
- {onFolderAddToList && (
382
- <button
383
- type="button"
384
- role="menuitem"
385
- className="cg-menu-item"
386
- onClick={() => {
387
- setAddTarget(folderMenuF.id);
388
- setAddAnchor(folderMenu.anchor);
389
- setAddName(today ? `${folderMenuF.name} Β· ${today}` : folderMenuF.name);
390
- setFolderMenu(null);
391
- }}
392
- >
393
- Add to cohort…
394
- </button>
395
- )}
396
- {onFolderDuplicate && (
397
- <button
398
- type="button"
399
- role="menuitem"
400
- className="cg-menu-item"
401
- onClick={() => {
402
- onFolderDuplicate(folderMenuF.id);
403
- setFolderMenu(null);
404
- }}
405
- >
406
- Duplicate folder and its cohorts
407
- </button>
408
- )}
409
- {onFolderDelete && (
410
- <button
411
- type="button"
412
- role="menuitem"
413
- className={
414
- "cg-menu-item cg-menu-item--danger" +
415
- (confirmFolderDelete === folderMenuF.id ? " is-armed" : "")
416
- }
417
- onClick={() => {
418
- if (confirmFolderDelete !== folderMenuF.id) {
419
- setConfirmFolderDelete(folderMenuF.id);
420
- return;
421
- }
422
- onFolderDelete(folderMenuF.id);
423
- setConfirmFolderDelete(null);
424
- setFolderMenu(null);
425
- }}
426
- >
427
- {confirmFolderDelete === folderMenuF.id
428
- ? "Delete folder? Its cohorts move to the top level."
429
- : "Delete folder"}
430
- </button>
431
- )}
432
- </AnchoredOverlay>
433
- )}
434
-
435
- {/* C4 as amended - the folder-level bulk add. A cohort folder's union needs
436
- no engine and has no refusals, so the pane states the deduped total and
437
- how many cohorts it came from, and nothing is ever silently omitted. */}
438
- {addTarget && addAnchor && onFolderAddToList && (
439
- <AnchoredOverlay
440
- anchor={addAnchor ?? undefined}
441
- className="cg-pop cg-fold-addpop"
442
- placement="bottom-start"
443
- role="dialog"
444
- ariaLabel="Add this folder's customers to a cohort"
445
- onDismiss={() => setAddTarget(null)}
446
- dataKind="folder-add-to-list"
447
- >
448
- <div className="cg-pop-title">Add to cohort</div>
449
- <div className="cg-pop-note">
450
- {addUnion.length.toLocaleString()} customer{addUnion.length === 1 ? "" : "s"} from{" "}
451
- {addCount.toLocaleString()} cohort{addCount === 1 ? "" : "s"} in this folder, counted
452
- once each.
453
- </div>
454
- {lists
455
- .filter((l) => !folderIdOf || folderIdOf(l.id) !== addTarget)
456
- .map((l) => (
457
- <button
458
- type="button"
459
- key={l.id}
460
- className="cg-pick-row"
461
- onClick={() => {
462
- onFolderAddToList(addTarget, l.id, l.name);
463
- setAddTarget(null);
464
- }}
465
- >
466
- {l.name}
467
- </button>
468
- ))}
469
- <div className="cg-view-create">
470
- <label htmlFor="cg-cfold-new-list">New cohort</label>
471
- <input
472
- id="cg-cfold-new-list"
473
- className="cg-input"
474
- data-overlay-autofocus
475
- value={addName}
476
- onChange={(e) => setAddName(e.target.value)}
477
- onKeyDown={(e) => {
478
- if (e.key === "Enter" && addName.trim()) {
479
- onFolderAddToList(addTarget, "", addName.trim());
480
- setAddTarget(null);
481
- }
482
- }}
483
- />
484
- <button
485
- type="button"
486
- className="cg-btn cg-btn--primary"
487
- disabled={!addName.trim() || addUnion.length === 0}
488
- onClick={() => {
489
- onFolderAddToList(addTarget, "", addName.trim());
490
- setAddTarget(null);
491
- }}
492
- >
493
- Create and add
494
- </button>
495
- </div>
496
- </AnchoredOverlay>
497
- )}
498
-
499
- {menu && menuCohort && (
500
- <AnchoredOverlay
501
- anchor={menu.anchor}
502
- className="cg-view-menu"
503
- placement="bottom-end"
504
- role="menu"
505
- ariaLabel={`Actions for ${menuCohort.name}`}
506
- onDismiss={closeMenu}
507
- onKeyDown={onMenuKeyDown}
508
- dataKind="cohort-menu"
509
- >
510
- {/* W2 (C2): Export sits ABOVE the rename/delete group, thin divider between. */}
511
- {onExport && (
512
- <>
513
- <button
514
- type="button"
515
- role="menuitem"
516
- onClick={() => {
517
- setExportFor({ cohortId: menuCohort.id, anchor: menu.anchor });
518
- closeMenu();
519
- }}
520
- >
521
- Export
522
- </button>
523
- <div className="cg-menu-sep" role="separator" aria-hidden />
524
- </>
525
- )}
526
- <button
527
- type="button"
528
- role="menuitem"
529
- onClick={() => {
530
- setRenamingId(menuCohort.id);
531
- closeMenu();
532
- }}
533
- >
534
- Rename
535
- </button>
536
- <button
537
- type="button"
538
- role="menuitem"
539
- className="is-danger"
540
- onClick={() => {
541
- if (!confirmDelete) {
542
- setConfirmDelete(true);
543
- return;
544
- }
545
- onDelete(menuCohort.id);
546
- closeMenu();
547
- }}
548
- >
549
- {confirmDelete ? "Delete permanently?" : "Delete"}
550
- </button>
551
- </AnchoredOverlay>
552
- )}
553
-
554
- {/* W2 (C2) β€” the format pane: CSV Β· Excel Β· PDF Β· JSON, keyboard-navigable like
555
- the menu it came from. */}
556
- {exportFor && exportCohort && onExport && (
557
- <AnchoredOverlay
558
- anchor={exportFor.anchor}
559
- className="cg-view-menu"
560
- placement="bottom-end"
561
- role="menu"
562
- ariaLabel={`Export ${exportCohort.name}`}
563
- onDismiss={() => setExportFor(null)}
564
- onKeyDown={onMenuKeyDown}
565
- dataKind="cohort-export"
566
- >
567
- <div className="cg-pop-title cg-export-title">Export {exportCohort.name}</div>
568
- {EXPORT_FORMATS.map((format) => (
569
- <button
570
- key={format}
571
- type="button"
572
- role="menuitem"
573
- onClick={() => {
574
- onExport(exportCohort.id, format);
575
- setExportFor(null);
576
- }}
577
- >
578
- {EXPORT_LABELS[format]}
579
- </button>
580
- ))}
581
- </AnchoredOverlay>
582
- )}
583
- </aside>
584
- );
585
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/customer-grid/cohortRail.ts DELETED
@@ -1,92 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / cohortRail.ts β€” wave-15 item 5 (contract C-LOCK, owner R10).
3
- //
4
- // THE COHORT MODULE IS GONE, AND ITS COHORTS ARE NOT. R10: every cohort
5
- // auto-projects as a LOCKED VIEW in a "Cohorts" section of the Customer rail,
6
- // the `#/cohort` route disappears, and all the cohort machinery stays reachable
7
- // from Customer. This is the projection β€” cohort list in, rail rows out.
8
- //
9
- // Pure, and separate from ViewSidebar, for the reason every projection in this
10
- // tree is: the rail is a React component no node gate can mount, and the thing
11
- // that must not regress is WHAT THE ROWS SAY β€” particularly a set the reader
12
- // cannot see, which is a permission surface rather than a display convenience.
13
- // `verify_cohort_lock.py` drives this directly.
14
- // ---------------------------------------------------------------------------
15
-
16
- /** The cohort shape the host already puts on `/workspace?scope=customer`
17
- * (`types.ts` WorkspaceWire.lists β€” verified against the shipped wire, not assumed). */
18
- export interface CohortWire {
19
- id: string;
20
- name: string;
21
- pids?: number[];
22
- missing?: number;
23
- folderId?: string | null;
24
- }
25
-
26
- export interface CohortRailRow {
27
- /** The cohort's own id. NOT a view id β€” these rows are projected, never saved. */
28
- id: string;
29
- name: string;
30
- /**
31
- * Members this reader can actually see, or `null` when the set was served without
32
- * membership. ⚠ `null` is NOT 0: "a list with nobody in it" and "a list I am not allowed to
33
- * count" are different sentences, and the engine already treats the second as unanswerable
34
- * (it matches NOTHING). A row that printed 0 for both would describe a set it must not
35
- * describe.
36
- */
37
- count: number | null;
38
- /** Members the host knows exist but did not send β€” disclosed, never folded into `count`. */
39
- missing: number;
40
- /** True while the grid is showing this cohort, so the rail can mark it the way it marks the
41
- * active view. */
42
- active: boolean;
43
- /** Always true. Present so the row renders through the same `cg-view-lockset` vocabulary a
44
- * saved locked view uses β€” one lock mark in the product, not two that drift. */
45
- locked: true;
46
- }
47
-
48
- /**
49
- * Project the cohorts onto rail rows.
50
- *
51
- * Ordering is BY NAME, deliberately, and not by size: the rail is how somebody finds a list
52
- * they already have in mind, and a size order re-arranges itself every time the data moves.
53
- * `localeCompare` so accented names file where a reader expects.
54
- */
55
- export function cohortRailRows(
56
- lists: readonly CohortWire[] | undefined,
57
- activeCohortId: string | null | undefined
58
- ): CohortRailRow[] {
59
- return [...(lists ?? [])]
60
- .filter((c) => !!c && typeof c.id === "string" && c.id !== "")
61
- .sort((a, b) => a.name.localeCompare(b.name))
62
- .map((c) => ({
63
- id: c.id,
64
- name: c.name,
65
- // `pids` ABSENT = no membership was served. An empty array is a real, countable empty set.
66
- count: Array.isArray(c.pids) ? c.pids.length : null,
67
- missing: typeof c.missing === "number" && c.missing > 0 ? c.missing : 0,
68
- active: !!activeCohortId && c.id === activeCohortId,
69
- locked: true,
70
- }));
71
- }
72
-
73
- /**
74
- * What the TOOLBAR chip says while a cohort lock is in force (item 5c: "the toolbar shows the
75
- * locked state near Filter/Sort/Group"). Until this wave the lock was stated only INSIDE the
76
- * filter popover, which meant the one control that explains a narrowed table was invisible
77
- * until you opened something.
78
- *
79
- * Three sentences, because there are three genuinely different states and only one of them is
80
- * ordinary:
81
- * - a set we can see -> name it, and say how many are in it
82
- * - a set we cannot see -> say THAT, because the table is empty for a reason that is not
83
- * a filter and the reader would otherwise hunt for one
84
- * - no lock -> no chip at all
85
- */
86
- export function cohortLockChipText(row: Pick<CohortRailRow, "name" | "count"> | null): string | null {
87
- if (!row) return null;
88
- if (row.count == null) return "Locked to a list you cannot see";
89
- return `${row.name} Β· ${row.count.toLocaleString()} ${
90
- row.count === 1 ? "customer" : "customers"
91
- }`;
92
- }