Prune files not in the deploy set (stale build context)
Browse files- api/pages_sales.py +0 -276
- web/src/automation/AutomationEditor.tsx +0 -662
- web/src/customer-grid/CohortSidebar.tsx +0 -585
- web/src/customer-grid/cohortRail.ts +0 -92
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’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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|