Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/automation_engine.py +1 -1
- api/routes_customers.py +24 -5
- api/routes_geo.py +80 -62
- api/routes_grid.py +22 -0
- api/routes_odoo_tables.py +217 -1
- api/routes_products.py +10 -0
- api/routes_tables.py +4 -0
- platform/aios_grid.py +152 -19
- platform/core/grid_events.py +104 -23
- platform/core/shared_overlay.py +34 -1
- platform/core/table_store.py +39 -1
- platform/core/user_tables.py +64 -5
- platform/modules/product_data.py +26 -0
- web/src/customer-grid/ColumnMenu.tsx +35 -3
- web/src/customer-grid/CustomerGrid.tsx +191 -14
- web/src/customer-grid/DatePicker.tsx +85 -144
- web/src/customer-grid/MapView.tsx +214 -114
- web/src/customer-grid/map.css +49 -1
- web/src/customer-grid/types.ts +49 -11
- web/src/shell/Shell.tsx +11 -10
- web/wiring_rows/wave43_shell_repair.py +25 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "57896aa",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
57896aa
|
api/automation_engine.py
CHANGED
|
@@ -1685,7 +1685,7 @@ def clean_config(kind, raw, previous=None):
|
|
| 1685 |
seen.add(pid)
|
| 1686 |
if not recipient:
|
| 1687 |
return None, "choose the Inbox recipient for this route"
|
| 1688 |
-
if not source_view or not revision:
|
| 1689 |
return None, "a scheduled route needs the source View and its frozen revision"
|
| 1690 |
if len(ordered) < 2:
|
| 1691 |
return None, "a scheduled route needs at least two ordered stops"
|
|
|
|
| 1685 |
seen.add(pid)
|
| 1686 |
if not recipient:
|
| 1687 |
return None, "choose the Inbox recipient for this route"
|
| 1688 |
+
if not source_view or source_view == "map-current" or not revision:
|
| 1689 |
return None, "a scheduled route needs the source View and its frozen revision"
|
| 1690 |
if len(ordered) < 2:
|
| 1691 |
return None, "a scheduled route needs at least two ordered stops"
|
api/routes_customers.py
CHANGED
|
@@ -463,6 +463,10 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 463 |
on_error=_measure_err) if may_metrics else {}).items():
|
| 464 |
derived.setdefault(pid, {}).update(cells)
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 467 |
"fields": fields, "views": views, "lists": lists, "derived": derived,
|
| 468 |
"measures": measures, "measure_sets": measure_sets, "today": today,
|
|
@@ -486,6 +490,9 @@ def _payload(session: Session):
|
|
| 486 |
g = grid_assembly(session)
|
| 487 |
rows = aios_grid.rows_from_pool(
|
| 488 |
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
|
|
|
|
|
|
|
|
|
| 489 |
# β C4 / D-138 (W30-T37) β THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
|
| 490 |
# (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
|
| 491 |
# complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
|
|
@@ -1621,11 +1628,22 @@ def route_delivery_create(body: dict = Body(default=None),
|
|
| 1621 |
body = body if isinstance(body, dict) else {}
|
| 1622 |
recipient = _route_delivery_recipient_or_400(body.get("recipient"))
|
| 1623 |
snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {}
|
| 1624 |
-
#
|
| 1625 |
-
#
|
| 1626 |
-
#
|
| 1627 |
-
|
| 1628 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1629 |
raw = {
|
| 1630 |
"name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120],
|
| 1631 |
"kind": "route_delivery",
|
|
@@ -1638,6 +1656,7 @@ def route_delivery_create(body: dict = Body(default=None),
|
|
| 1638 |
**snapshot,
|
| 1639 |
"sourceViewId": source_view_id,
|
| 1640 |
"sourceViewRevision": source_revision,
|
|
|
|
| 1641 |
"mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)),
|
| 1642 |
},
|
| 1643 |
},
|
|
|
|
| 463 |
on_error=_measure_err) if may_metrics else {}).items():
|
| 464 |
derived.setdefault(pid, {}).update(cells)
|
| 465 |
|
| 466 |
+
# Zones are table-scoped tenant data, surfaced as the established comma-joined multiselect
|
| 467 |
+
# contract so saved Views and FilterBuilder evaluate them like every other membership field.
|
| 468 |
+
from core import user_tables as _zones
|
| 469 |
+
fields = _zones.with_zone_field("customer_data", fields, st=rt)
|
| 470 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 471 |
"fields": fields, "views": views, "lists": lists, "derived": derived,
|
| 472 |
"measures": measures, "measure_sets": measure_sets, "today": today,
|
|
|
|
| 490 |
g = grid_assembly(session)
|
| 491 |
rows = aios_grid.rows_from_pool(
|
| 492 |
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 493 |
+
from core import user_tables as _zones
|
| 494 |
+
rows = _zones.zone_cells("customer_data", rows, allowed_row_ids=g["pids"],
|
| 495 |
+
st=session.runtime)
|
| 496 |
# β C4 / D-138 (W30-T37) β THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
|
| 497 |
# (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
|
| 498 |
# complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
|
|
|
|
| 1628 |
body = body if isinstance(body, dict) else {}
|
| 1629 |
recipient = _route_delivery_recipient_or_400(body.get("recipient"))
|
| 1630 |
snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {}
|
| 1631 |
+
# A route snapshot is allowed to freeze its *stops*, never to invent its View provenance.
|
| 1632 |
+
# `map-current`/a route-plan fingerprint were a synthetic fallback that made an automation
|
| 1633 |
+
# impossible to audit back to the actual filtered View. Resolve the submitted id against
|
| 1634 |
+
# this reader's visible View contract and retain the supplied canonical config revision.
|
| 1635 |
+
source_view_id = str(snapshot.get("sourceViewId") or "").strip()
|
| 1636 |
+
source_revision = str(snapshot.get("sourceViewRevision") or "").strip()
|
| 1637 |
+
if not source_view_id or source_view_id == "map-current" or not source_revision:
|
| 1638 |
+
raise err(400, "invalid_route_source_view",
|
| 1639 |
+
"choose an active saved View and include its frozen revision")
|
| 1640 |
+
source_views = {str(view.get("id") or ""): view for view in
|
| 1641 |
+
(grid_assembly(session, consume_corrections=False).get("views") or [])
|
| 1642 |
+
if isinstance(view, dict) and view.get("id")}
|
| 1643 |
+
source_view = source_views.get(source_view_id)
|
| 1644 |
+
if source_view is None:
|
| 1645 |
+
raise err(400, "unknown_route_source_view",
|
| 1646 |
+
"the route source View is no longer visible to this account")
|
| 1647 |
raw = {
|
| 1648 |
"name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120],
|
| 1649 |
"kind": "route_delivery",
|
|
|
|
| 1656 |
**snapshot,
|
| 1657 |
"sourceViewId": source_view_id,
|
| 1658 |
"sourceViewRevision": source_revision,
|
| 1659 |
+
"sourceViewName": str(source_view.get("name") or source_view_id),
|
| 1660 |
"mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)),
|
| 1661 |
},
|
| 1662 |
},
|
api/routes_geo.py
CHANGED
|
@@ -45,7 +45,7 @@ import time
|
|
| 45 |
from collections import OrderedDict
|
| 46 |
|
| 47 |
import requests
|
| 48 |
-
from fastapi import APIRouter, Body, Depends
|
| 49 |
|
| 50 |
from deps import Session, err, require_session
|
| 51 |
|
|
@@ -210,13 +210,47 @@ def _clean_zone(raw, previous=None, *, zone_id=""):
|
|
| 210 |
|
| 211 |
|
| 212 |
def _zone_wire(zone):
|
| 213 |
-
|
| 214 |
"geographicField", "memberships", "createdBy", "createdAt")}
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
if not isinstance(zone, dict):
|
| 221 |
raise err(404, "unknown_zone", "no Zone with that id")
|
| 222 |
if not session.admin and str(zone.get("createdBy") or "") != session.uname:
|
|
@@ -394,15 +428,13 @@ def geo_providers(session: Session = Depends(require_session)):
|
|
| 394 |
|
| 395 |
|
| 396 |
@router.get("/geo/zones")
|
| 397 |
-
def geo_zones(session: Session = Depends(require_session)):
|
| 398 |
-
"""The
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
# Visibility is a display choice carried by the durable zone itself. Creators and admins keep
|
| 405 |
-
# seeing an invisible zone in the chooser so it can be restored or edited rather than orphaned.
|
| 406 |
visible = [zone for zone in zones if zone.get("visible", True)
|
| 407 |
or session.admin or str(zone.get("createdBy") or "") == session.uname]
|
| 408 |
return {"zones": [_zone_wire(zone) for zone in sorted(visible,
|
|
@@ -411,71 +443,57 @@ def geo_zones(session: Session = Depends(require_session)):
|
|
| 411 |
|
| 412 |
@router.post("/geo/zones")
|
| 413 |
def geo_zone_create(body: dict = Body(default=None), session: Session = Depends(require_session)):
|
| 414 |
-
"""Persist one
|
|
|
|
| 415 |
body = body if isinstance(body, dict) else {}
|
|
|
|
| 416 |
try:
|
| 417 |
zone = _clean_zone(body)
|
| 418 |
except ValueError as exc:
|
| 419 |
raise err(400, "invalid_zone", str(exc))
|
| 420 |
if not zone["id"]:
|
| 421 |
raise err(400, "invalid_zone", "name this Zone")
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
current[zone["id"]] = item
|
| 432 |
-
created.update(item)
|
| 433 |
-
return current
|
| 434 |
-
try:
|
| 435 |
-
session.runtime.update(ZONES_KEY, _write, flush="async")
|
| 436 |
-
except ValueError as exc:
|
| 437 |
-
raise err(409, "zone_exists", str(exc))
|
| 438 |
-
return {"zone": _zone_wire(created)}
|
| 439 |
|
| 440 |
|
| 441 |
@router.patch("/geo/zones/{zone_id}")
|
| 442 |
def geo_zone_update(zone_id: str, body: dict = Body(default=None),
|
| 443 |
session: Session = Depends(require_session)):
|
| 444 |
-
"""Edit
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
| 446 |
try:
|
| 447 |
zone = _clean_zone(body, previous, zone_id=str(zone_id))
|
| 448 |
except ValueError as exc:
|
| 449 |
raise err(400, "invalid_zone", str(exc))
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
raise KeyError(zone_id)
|
| 457 |
-
item = {**zone, "createdBy": old.get("createdBy") or session.uname,
|
| 458 |
-
"createdAt": old.get("createdAt") or time.strftime("%Y-%m-%dT%H:%M:%S")}
|
| 459 |
-
current[str(zone_id)] = item
|
| 460 |
-
updated.update(item)
|
| 461 |
-
return current
|
| 462 |
-
try:
|
| 463 |
-
session.runtime.update(ZONES_KEY, _write, flush="async")
|
| 464 |
-
except KeyError:
|
| 465 |
-
raise err(404, "unknown_zone", "no Zone with that id")
|
| 466 |
-
return {"zone": _zone_wire(updated)}
|
| 467 |
|
| 468 |
|
| 469 |
@router.delete("/geo/zones/{zone_id}")
|
| 470 |
-
def geo_zone_delete(zone_id: str,
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
return {"deleted": str(zone_id)}
|
| 480 |
|
| 481 |
|
|
|
|
| 45 |
from collections import OrderedDict
|
| 46 |
|
| 47 |
import requests
|
| 48 |
+
from fastapi import APIRouter, Body, Depends, Query
|
| 49 |
|
| 50 |
from deps import Session, err, require_session
|
| 51 |
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
def _zone_wire(zone):
|
| 213 |
+
wire = {key: zone.get(key) for key in ("id", "name", "color", "geometryGeoJson", "visible",
|
| 214 |
"geographicField", "memberships", "createdBy", "createdAt")}
|
| 215 |
+
# Storage keeps ids as strings because table records may originate in several backends; Map
|
| 216 |
+
# selection is numeric `pid`s. The wire performs that one boundary conversion explicitly.
|
| 217 |
+
members = []
|
| 218 |
+
for raw in zone.get("memberships") or ():
|
| 219 |
+
try:
|
| 220 |
+
pid = int(raw)
|
| 221 |
+
except (TypeError, ValueError):
|
| 222 |
+
continue
|
| 223 |
+
if pid > 0:
|
| 224 |
+
members.append(pid)
|
| 225 |
+
wire["memberships"] = members
|
| 226 |
+
return wire
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _zone_context(session, raw_table_key):
|
| 230 |
+
"""One table-scoped Zone context, using the existing grid assemblies as permission walls."""
|
| 231 |
+
table_key = str(raw_table_key or "").strip()
|
| 232 |
+
if table_key == "customer_data":
|
| 233 |
+
from routes_customers import MODULE as customer_module, grid_assembly
|
| 234 |
+
session.require(customer_module)
|
| 235 |
+
grid = grid_assembly(session, consume_corrections=False)
|
| 236 |
+
elif table_key == "product_data":
|
| 237 |
+
from routes_products import MODULE as product_module, product_assembly
|
| 238 |
+
session.require(product_module)
|
| 239 |
+
grid = product_assembly(session, consume_corrections=False)
|
| 240 |
+
elif table_key.startswith("ut_"):
|
| 241 |
+
from routes_tables import ut_assembly
|
| 242 |
+
grid = ut_assembly(session, table_key, consume_corrections=False, with_rows=False)
|
| 243 |
+
else:
|
| 244 |
+
raise err(400, "invalid_zone_table", "choose a database before working with Zones")
|
| 245 |
+
fields = {str(field.get("key") or "") for field in (grid.get("fields") or [])
|
| 246 |
+
if isinstance(field, dict) and field.get("key")}
|
| 247 |
+
return table_key, fields, set(grid.get("pids") or ())
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _zone_subject(session, table_key, zone_id):
|
| 251 |
+
from core import user_tables
|
| 252 |
+
zone = next((item for item in user_tables.zones(table_key, st=session.runtime)
|
| 253 |
+
if str(item.get("id") or "") == str(zone_id)), None)
|
| 254 |
if not isinstance(zone, dict):
|
| 255 |
raise err(404, "unknown_zone", "no Zone with that id")
|
| 256 |
if not session.admin and str(zone.get("createdBy") or "") != session.uname:
|
|
|
|
| 428 |
|
| 429 |
|
| 430 |
@router.get("/geo/zones")
|
| 431 |
+
def geo_zones(tableKey: str = Query(default=""), session: Session = Depends(require_session)):
|
| 432 |
+
"""The canonical table-scoped Zone DTOs a Map may draw or filter by."""
|
| 433 |
+
from core import user_tables
|
| 434 |
+
table_key, _fields, _pids = _zone_context(session, tableKey)
|
| 435 |
+
zones = user_tables.zones(table_key, st=session.runtime)
|
| 436 |
+
# A hidden Zone remains recoverable to its creator/admin, but cannot become visible through a
|
| 437 |
+
# second table simply because row ids happen to collide there.
|
|
|
|
|
|
|
| 438 |
visible = [zone for zone in zones if zone.get("visible", True)
|
| 439 |
or session.admin or str(zone.get("createdBy") or "") == session.uname]
|
| 440 |
return {"zones": [_zone_wire(zone) for zone in sorted(visible,
|
|
|
|
| 443 |
|
| 444 |
@router.post("/geo/zones")
|
| 445 |
def geo_zone_create(body: dict = Body(default=None), session: Session = Depends(require_session)):
|
| 446 |
+
"""Persist one Zone in the existing table-scoped Zone store and field/filter contract."""
|
| 447 |
+
from core import user_tables
|
| 448 |
body = body if isinstance(body, dict) else {}
|
| 449 |
+
table_key, field_keys, pids = _zone_context(session, body.get("tableKey"))
|
| 450 |
try:
|
| 451 |
zone = _clean_zone(body)
|
| 452 |
except ValueError as exc:
|
| 453 |
raise err(400, "invalid_zone", str(exc))
|
| 454 |
if not zone["id"]:
|
| 455 |
raise err(400, "invalid_zone", "name this Zone")
|
| 456 |
+
if any(str(item.get("id") or "") == zone["id"]
|
| 457 |
+
for item in user_tables.zones(table_key, st=session.runtime)):
|
| 458 |
+
raise err(409, "zone_exists", "a Zone with that name already exists")
|
| 459 |
+
saved = user_tables.upsert_zone(table_key, zone, field_keys=field_keys,
|
| 460 |
+
allowed_row_ids=pids, st=session.runtime,
|
| 461 |
+
created_by=session.uname)
|
| 462 |
+
if saved is None:
|
| 463 |
+
raise err(400, "invalid_zone", "the Zone does not match this database's fields and records")
|
| 464 |
+
return {"zone": _zone_wire(saved)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
|
| 466 |
|
| 467 |
@router.patch("/geo/zones/{zone_id}")
|
| 468 |
def geo_zone_update(zone_id: str, body: dict = Body(default=None),
|
| 469 |
session: Session = Depends(require_session)):
|
| 470 |
+
"""Edit a table-scoped Zone while retaining creator, geometry and membership invariants."""
|
| 471 |
+
from core import user_tables
|
| 472 |
+
body = body if isinstance(body, dict) else {}
|
| 473 |
+
table_key, field_keys, pids = _zone_context(session, body.get("tableKey"))
|
| 474 |
+
previous = _zone_subject(session, table_key, zone_id)
|
| 475 |
try:
|
| 476 |
zone = _clean_zone(body, previous, zone_id=str(zone_id))
|
| 477 |
except ValueError as exc:
|
| 478 |
raise err(400, "invalid_zone", str(exc))
|
| 479 |
+
saved = user_tables.upsert_zone(table_key, zone, field_keys=field_keys,
|
| 480 |
+
allowed_row_ids=pids, st=session.runtime,
|
| 481 |
+
created_by=session.uname)
|
| 482 |
+
if saved is None:
|
| 483 |
+
raise err(400, "invalid_zone", "the Zone does not match this database's fields and records")
|
| 484 |
+
return {"zone": _zone_wire(saved)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
|
| 486 |
|
| 487 |
@router.delete("/geo/zones/{zone_id}")
|
| 488 |
+
def geo_zone_delete(zone_id: str, body: dict = Body(default=None),
|
| 489 |
+
session: Session = Depends(require_session)):
|
| 490 |
+
"""Delete exactly one table-scoped Zone; record values are never mutated here."""
|
| 491 |
+
body = body if isinstance(body, dict) else {}
|
| 492 |
+
table_key, _fields, _pids = _zone_context(session, body.get("tableKey"))
|
| 493 |
+
_zone_subject(session, table_key, zone_id)
|
| 494 |
+
from core import user_tables
|
| 495 |
+
if not user_tables.delete_zone(table_key, zone_id, st=session.runtime):
|
| 496 |
+
raise err(404, "unknown_zone", "no Zone with that id")
|
| 497 |
return {"deleted": str(zone_id)}
|
| 498 |
|
| 499 |
|
api/routes_grid.py
CHANGED
|
@@ -840,6 +840,28 @@ def bulk_cells(body: dict = Body(default=None),
|
|
| 840 |
return out
|
| 841 |
|
| 842 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
@router.post("/grid/events")
|
| 844 |
def grid_events_route(body: dict = Body(default=None),
|
| 845 |
session: Session = Depends(require_session)):
|
|
|
|
| 840 |
return out
|
| 841 |
|
| 842 |
|
| 843 |
+
@router.post("/grid/recurrence-preview")
|
| 844 |
+
def recurrence_preview(body: dict = Body(default=None),
|
| 845 |
+
session: Session = Depends(require_session)):
|
| 846 |
+
"""Validate and preview a Date-field recurrence before the user saves it.
|
| 847 |
+
|
| 848 |
+
A preview is not a client-side convenience calculation: holiday relations and ordinal-month
|
| 849 |
+
rules must be evaluated by the same server normaliser that persists the field. This endpoint
|
| 850 |
+
is read-only and session-scoped only because it has no table or row to disclose.
|
| 851 |
+
"""
|
| 852 |
+
import aios_grid
|
| 853 |
+
|
| 854 |
+
raw = (body or {}).get("recurrence")
|
| 855 |
+
clean = aios_grid.clean_date_recurrence(raw)
|
| 856 |
+
if clean is None:
|
| 857 |
+
raise err(400, "bad_recurrence",
|
| 858 |
+
"recurrence needs a start date and at least one supported repeat rule")
|
| 859 |
+
return {
|
| 860 |
+
"recurrence": aios_grid.recurrence_payload(clean, today=time.strftime("%Y-%m-%d")),
|
| 861 |
+
"calendars": list(aios_grid.PUBLIC_HOLIDAY_CALENDARS),
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
|
| 865 |
@router.post("/grid/events")
|
| 866 |
def grid_events_route(body: dict = Body(default=None),
|
| 867 |
session: Session = Depends(require_session)):
|
api/routes_odoo_tables.py
CHANGED
|
@@ -18,9 +18,13 @@ and deletes the rows that left the population. That is an operator action.
|
|
| 18 |
READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
|
| 19 |
read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
|
| 20 |
"""
|
|
|
|
|
|
|
|
|
|
| 21 |
import json
|
|
|
|
| 22 |
|
| 23 |
-
from fastapi import Depends
|
| 24 |
from fastapi import APIRouter, Query
|
| 25 |
|
| 26 |
from deps import Session, err, require_session
|
|
@@ -780,6 +784,14 @@ def _source_for(cur, table_key):
|
|
| 780 |
#: the person BEFORE the wait rather than after it.
|
| 781 |
_DEEP_PAGE = 100_000
|
| 782 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
|
| 784 |
#: `filter_sql` speaks the CLIENT's type vocabulary (`types.ts isNumericType`), and two of our
|
| 785 |
#: field kinds are spelled differently there. Mapped in one place so the pushdown and the grid
|
|
@@ -831,6 +843,210 @@ def _json_arg(raw, what):
|
|
| 831 |
return val
|
| 832 |
|
| 833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
@router.get("/odoo-tables/{table_key}/rows")
|
| 835 |
def odoo_table_rows(table_key: str,
|
| 836 |
offset: int = Query(default=0, ge=0),
|
|
|
|
| 18 |
READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
|
| 19 |
read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
|
| 20 |
"""
|
| 21 |
+
import base64
|
| 22 |
+
import hashlib
|
| 23 |
+
import hmac
|
| 24 |
import json
|
| 25 |
+
import time
|
| 26 |
|
| 27 |
+
from fastapi import Body, Depends
|
| 28 |
from fastapi import APIRouter, Query
|
| 29 |
|
| 30 |
from deps import Session, err, require_session
|
|
|
|
| 784 |
#: the person BEFORE the wait rather than after it.
|
| 785 |
_DEEP_PAGE = 100_000
|
| 786 |
|
| 787 |
+
# A filtered "select all" is a WRITE precursor, not a normal source read. The source itself is
|
| 788 |
+
# intentionally uncapped, but this descriptor is later used to write one shared field to every
|
| 789 |
+
# selected row in one durable overlay transaction. Keep the existing bulk-write ceiling explicit
|
| 790 |
+
# and report it before minting a descriptor; never turn a 34,000-row filter into an unannounced
|
| 791 |
+
# first-page selection.
|
| 792 |
+
MAX_FILTERED_SELECTION_ROWS = 10_000
|
| 793 |
+
FILTERED_SELECTION_SECONDS = 5 * 60
|
| 794 |
+
|
| 795 |
|
| 796 |
#: `filter_sql` speaks the CLIENT's type vocabulary (`types.ts isNumericType`), and two of our
|
| 797 |
#: field kinds are spelled differently there. Mapped in one place so the pushdown and the grid
|
|
|
|
| 843 |
return val
|
| 844 |
|
| 845 |
|
| 846 |
+
# ---------------------------------------------------------------- filtered selection / bulk overlay writes
|
| 847 |
+
|
| 848 |
+
def _selection_query(body):
|
| 849 |
+
"""The exact client predicate a server-windowed selection represents.
|
| 850 |
+
|
| 851 |
+
The normal rows route owns the SQL compiler, row wall and hidden-field closure. This helper
|
| 852 |
+
only validates the wire shape and preserves it verbatim so both descriptor minting and the
|
| 853 |
+
later write can call that same door. Falling back to an empty filter here would widen a bulk
|
| 854 |
+
write, which is worse than refusing a malformed request.
|
| 855 |
+
"""
|
| 856 |
+
body = body if isinstance(body, dict) else {}
|
| 857 |
+
filters = body.get("filters", [])
|
| 858 |
+
sorts = body.get("sorts", [])
|
| 859 |
+
if filters is None:
|
| 860 |
+
filters = []
|
| 861 |
+
if sorts is None:
|
| 862 |
+
sorts = []
|
| 863 |
+
if not isinstance(filters, list):
|
| 864 |
+
raise err(400, "bad_selection", "filters must be a list")
|
| 865 |
+
if not isinstance(sorts, list):
|
| 866 |
+
raise err(400, "bad_selection", "sorts must be a list")
|
| 867 |
+
filter_conj = str(body.get("filterConj") or "and").lower()
|
| 868 |
+
if filter_conj not in ("and", "or"):
|
| 869 |
+
raise err(400, "bad_selection", "filterConj must be 'and' or 'or'")
|
| 870 |
+
search = body.get("search", "")
|
| 871 |
+
if search is None:
|
| 872 |
+
search = ""
|
| 873 |
+
if not isinstance(search, str) or len(search) > 1_000:
|
| 874 |
+
raise err(400, "bad_selection", "search must be text no longer than 1,000 characters")
|
| 875 |
+
return {"filters": filters, "filterConj": filter_conj, "sorts": sorts, "search": search}
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def _selection_rows_args(query, *, offset, limit):
|
| 879 |
+
"""Turn a canonical descriptor predicate back into `odoo_table_rows` query arguments."""
|
| 880 |
+
return {
|
| 881 |
+
"offset": offset,
|
| 882 |
+
"limit": limit,
|
| 883 |
+
"filters": json.dumps(query["filters"], separators=(",", ":")),
|
| 884 |
+
"filterConj": query["filterConj"],
|
| 885 |
+
"sorts": json.dumps(query["sorts"], separators=(",", ":")),
|
| 886 |
+
"search": query["search"],
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
def _pid_fingerprint(pids):
|
| 891 |
+
"""Stable set fingerprint for a descriptor; duplicate/non-integer IDs are an internal refusal."""
|
| 892 |
+
try:
|
| 893 |
+
ordered = sorted(int(pid) for pid in pids)
|
| 894 |
+
except (TypeError, ValueError):
|
| 895 |
+
raise ValueError("the connected rows route returned a non-integer record id")
|
| 896 |
+
if len(set(ordered)) != len(ordered):
|
| 897 |
+
raise ValueError("the connected rows route returned a duplicate record id")
|
| 898 |
+
return hashlib.sha256(",".join(str(pid) for pid in ordered).encode("ascii")).hexdigest()
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
def _all_filtered_pids(table_key, query, session):
|
| 902 |
+
"""Read every permitted ID through the existing read compiler, with an explicit write bound."""
|
| 903 |
+
from harness import datastore
|
| 904 |
+
|
| 905 |
+
first = odoo_table_rows(table_key, session=session,
|
| 906 |
+
**_selection_rows_args(query, offset=0,
|
| 907 |
+
limit=datastore.WINDOW_MAX))
|
| 908 |
+
total = int(first.get("total") or 0)
|
| 909 |
+
if total > MAX_FILTERED_SELECTION_ROWS:
|
| 910 |
+
raise err(400, "selection_too_large",
|
| 911 |
+
f"this filter matches {total:,} records; bulk updates allow at most "
|
| 912 |
+
f"{MAX_FILTERED_SELECTION_ROWS:,}. Narrow the filter and select all again")
|
| 913 |
+
if total == 0:
|
| 914 |
+
return [], 0
|
| 915 |
+
|
| 916 |
+
pages = [first]
|
| 917 |
+
for offset in range(datastore.WINDOW_MAX, total, datastore.WINDOW_MAX):
|
| 918 |
+
page = odoo_table_rows(table_key, session=session,
|
| 919 |
+
**_selection_rows_args(query, offset=offset,
|
| 920 |
+
limit=datastore.WINDOW_MAX))
|
| 921 |
+
if int(page.get("total") or 0) != total:
|
| 922 |
+
raise err(409, "selection_stale",
|
| 923 |
+
"the filtered result changed while it was being selected; review and select all again")
|
| 924 |
+
pages.append(page)
|
| 925 |
+
pids = [row.get("pid") for page in pages for row in (page.get("rows") or [])]
|
| 926 |
+
try:
|
| 927 |
+
fingerprint = _pid_fingerprint(pids)
|
| 928 |
+
except ValueError as exc:
|
| 929 |
+
raise err(409, "selection_stale", str(exc))
|
| 930 |
+
if len(pids) != total:
|
| 931 |
+
raise err(409, "selection_stale",
|
| 932 |
+
"the filtered result changed while it was being selected; review and select all again")
|
| 933 |
+
return pids, fingerprint
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def _mint_selection_token(claims):
|
| 937 |
+
"""A short, signed opaque descriptor; it carries no authority across user/tenant/table."""
|
| 938 |
+
import aios_session
|
| 939 |
+
|
| 940 |
+
raw = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 941 |
+
body = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
| 942 |
+
# Domain separation prevents an otherwise valid selection descriptor being confused with a
|
| 943 |
+
# session signature anywhere else in the process.
|
| 944 |
+
signature = aios_session._sign(b"filtered-selection:v1:" + raw)
|
| 945 |
+
return f"{body}.{signature}"
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def _read_selection_token(token):
|
| 949 |
+
"""Parse/verify an opaque descriptor without revealing which attacker-controlled part failed."""
|
| 950 |
+
import aios_session
|
| 951 |
+
|
| 952 |
+
if not isinstance(token, str) or len(token) > 16_384 or token.count(".") != 1:
|
| 953 |
+
return None
|
| 954 |
+
body, signature = token.split(".", 1)
|
| 955 |
+
try:
|
| 956 |
+
raw = base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
|
| 957 |
+
except Exception: # noqa: BLE001
|
| 958 |
+
return None
|
| 959 |
+
if not hmac.compare_digest(signature, aios_session._sign(b"filtered-selection:v1:" + raw)):
|
| 960 |
+
return None
|
| 961 |
+
try:
|
| 962 |
+
claims = json.loads(raw.decode("utf-8"))
|
| 963 |
+
except Exception: # noqa: BLE001
|
| 964 |
+
return None
|
| 965 |
+
return claims if isinstance(claims, dict) else None
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
@router.post("/odoo-tables/{table_key}/filtered-selection")
|
| 969 |
+
def create_filtered_selection(table_key: str, body: dict = Body(default=None),
|
| 970 |
+
session: Session = Depends(require_session)):
|
| 971 |
+
"""Mint an opaque all-matching descriptor for a windowed grid.
|
| 972 |
+
|
| 973 |
+
The response count is the rows route's server count, and the descriptor fingerprints every
|
| 974 |
+
matching permitted PID. The browser never gets a fabricated set from its loaded page.
|
| 975 |
+
"""
|
| 976 |
+
query = _selection_query(body)
|
| 977 |
+
pids, matched_or_fingerprint = _all_filtered_pids(table_key, query, session)
|
| 978 |
+
if not pids:
|
| 979 |
+
return {"ok": True, "selection": None, "matched": 0, "expiresAt": None}
|
| 980 |
+
now = int(time.time())
|
| 981 |
+
claims = {
|
| 982 |
+
"v": 1, "tenant": session.tenant, "user": session.uname, "table": table_key,
|
| 983 |
+
"query": query, "matched": len(pids), "fingerprint": matched_or_fingerprint,
|
| 984 |
+
"exp": now + FILTERED_SELECTION_SECONDS,
|
| 985 |
+
}
|
| 986 |
+
return {"ok": True, "selection": _mint_selection_token(claims), "matched": len(pids),
|
| 987 |
+
"expiresAt": claims["exp"]}
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
@router.post("/odoo-tables/{table_key}/filtered-bulk")
|
| 991 |
+
def apply_filtered_bulk(table_key: str, body: dict = Body(default=None),
|
| 992 |
+
session: Session = Depends(require_session)):
|
| 993 |
+
"""Revalidate an all-matching descriptor and write one existing shared field atomically."""
|
| 994 |
+
import core.perm_scope as perm_scope
|
| 995 |
+
import core.shared_overlay as shared_overlay
|
| 996 |
+
import core.shares as shares
|
| 997 |
+
from routes_tables import _defn_or_refuse
|
| 998 |
+
|
| 999 |
+
body = body if isinstance(body, dict) else {}
|
| 1000 |
+
claims = _read_selection_token(body.get("selection"))
|
| 1001 |
+
if not claims:
|
| 1002 |
+
raise err(400, "bad_selection", "the filtered selection is invalid; select all again")
|
| 1003 |
+
if (claims.get("v") != 1 or claims.get("tenant") != session.tenant
|
| 1004 |
+
or claims.get("user") != session.uname or claims.get("table") != table_key):
|
| 1005 |
+
raise err(403, "selection_forbidden",
|
| 1006 |
+
"that filtered selection belongs to a different account, tenant, or database")
|
| 1007 |
+
if int(claims.get("exp") or 0) <= int(time.time()):
|
| 1008 |
+
raise err(409, "selection_stale", "the filtered selection expired; select all again")
|
| 1009 |
+
query = claims.get("query")
|
| 1010 |
+
try:
|
| 1011 |
+
query = _selection_query(query)
|
| 1012 |
+
except Exception:
|
| 1013 |
+
raise err(400, "bad_selection", "the filtered selection is invalid; select all again")
|
| 1014 |
+
|
| 1015 |
+
# Reuse the same compiler/walls and compare the actual matching set before touching a cell.
|
| 1016 |
+
pids, fingerprint = _all_filtered_pids(table_key, query, session)
|
| 1017 |
+
if (len(pids) != int(claims.get("matched") or -1)
|
| 1018 |
+
or fingerprint != claims.get("fingerprint")):
|
| 1019 |
+
raise err(409, "selection_stale",
|
| 1020 |
+
"the filtered result changed before this bulk update; review and select all again")
|
| 1021 |
+
|
| 1022 |
+
key = str(body.get("field") or "").strip()
|
| 1023 |
+
if not key:
|
| 1024 |
+
raise err(400, "bad_request", "a field key is required")
|
| 1025 |
+
# `_all_filtered_pids` already ran the read wall. This second, write-shaped check keeps a
|
| 1026 |
+
# projected definition from leaking into a write path and makes the capability explicit.
|
| 1027 |
+
_defn_or_refuse(session, table_key)
|
| 1028 |
+
if not shares.may_edit("database", table_key, session.uname, is_admin=session.admin,
|
| 1029 |
+
st=session.runtime):
|
| 1030 |
+
raise err(403, "forbidden",
|
| 1031 |
+
"an edit share on this database is required to update its shared field")
|
| 1032 |
+
definitions = shared_overlay.fields(table_key, st=session.runtime)
|
| 1033 |
+
if key not in definitions:
|
| 1034 |
+
raise err(400, "field_not_shared", "bulk updates on connected data require an existing shared field")
|
| 1035 |
+
if key in perm_scope.hidden_keys(session.user, table_key, list(definitions.values())):
|
| 1036 |
+
raise err(403, "field_hidden", "you do not have access to that field")
|
| 1037 |
+
value = body.get("value")
|
| 1038 |
+
if isinstance(value, (dict, list, tuple, set)):
|
| 1039 |
+
raise err(400, "bad_value", "a bulk value must be a scalar")
|
| 1040 |
+
try:
|
| 1041 |
+
written = shared_overlay.put_rows(table_key, {pid: {key: value} for pid in pids},
|
| 1042 |
+
st=session.runtime)
|
| 1043 |
+
except ValueError as exc:
|
| 1044 |
+
raise err(400, "bad_value", str(exc))
|
| 1045 |
+
return {"ok": True, "matched": len(pids), "rows_requested": len(pids),
|
| 1046 |
+
"rows_written": len(written),
|
| 1047 |
+
"cells_written": sum(len(cells) for cells in written.values())}
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
@router.get("/odoo-tables/{table_key}/rows")
|
| 1051 |
def odoo_table_rows(table_key: str,
|
| 1052 |
offset: int = Query(default=0, ge=0),
|
api/routes_products.py
CHANGED
|
@@ -372,6 +372,11 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 372 |
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 373 |
fields_base=fields_base)
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
have = {field.get("key") for field in fields if isinstance(field, dict)}
|
| 376 |
projected = []
|
| 377 |
from core import shares
|
|
@@ -415,6 +420,8 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 415 |
# would trade one honest absence for a spinner that never resolves.
|
| 416 |
measure_sets = _measure_condition_sets(views, measures, rows_src, team_id, today)
|
| 417 |
|
|
|
|
|
|
|
| 418 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 419 |
"fields": fields, "views": views, "lists": lists,
|
| 420 |
"derived": derived,
|
|
@@ -439,6 +446,9 @@ def products(session: Session = Depends(module_gate(MODULE))):
|
|
| 439 |
rows = aios_grid.rows_from_pool(
|
| 440 |
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 441 |
rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
|
|
|
|
|
|
|
|
|
|
| 442 |
# ββ W41-T17 (owner instruction 25) β THE IDENTITY REPORT RIDES THE ENVELOPE, because a
|
| 443 |
# report nothing reads is not a report. `product_data.identity_report` is standing rule 1's
|
| 444 |
# second sentence as data for this grid's two identity columns: `product_id` (the Odoo
|
|
|
|
| 372 |
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 373 |
fields_base=fields_base)
|
| 374 |
|
| 375 |
+
# W43: source-backed Product choices learn values in the shared overlay schema. Apply that
|
| 376 |
+
# durable vocabulary before the payload is served, so "Add option: et" is still available
|
| 377 |
+
# after a refresh and for collaborators, while canonical JSON choices retain their order.
|
| 378 |
+
fields = aios_grid.apply_choice_overrides(fields, shared_defs)
|
| 379 |
+
|
| 380 |
have = {field.get("key") for field in fields if isinstance(field, dict)}
|
| 381 |
projected = []
|
| 382 |
from core import shares
|
|
|
|
| 420 |
# would trade one honest absence for a spinner that never resolves.
|
| 421 |
measure_sets = _measure_condition_sets(views, measures, rows_src, team_id, today)
|
| 422 |
|
| 423 |
+
from core import user_tables as _zones
|
| 424 |
+
fields = _zones.with_zone_field("product_data", fields, st=session.runtime)
|
| 425 |
return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| 426 |
"fields": fields, "views": views, "lists": lists,
|
| 427 |
"derived": derived,
|
|
|
|
| 446 |
rows = aios_grid.rows_from_pool(
|
| 447 |
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 448 |
rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
|
| 449 |
+
from core import user_tables as _zones
|
| 450 |
+
rows = _zones.zone_cells("product_data", rows, allowed_row_ids=g["pids"],
|
| 451 |
+
st=session.runtime)
|
| 452 |
# ββ W41-T17 (owner instruction 25) β THE IDENTITY REPORT RIDES THE ENVELOPE, because a
|
| 453 |
# report nothing reads is not a report. `product_data.identity_report` is standing rule 1's
|
| 454 |
# second sentence as data for this grid's two identity columns: `product_id` (the Odoo
|
api/routes_tables.py
CHANGED
|
@@ -1340,6 +1340,9 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
|
| 1340 |
# ββ W36-T21 / R6 β the READ half, in `grid_assembly`'s own position: after `workspace_wire`,
|
| 1341 |
# so the closure covers this user's `custom_` and `measure_` columns too.
|
| 1342 |
fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src, st=st)
|
|
|
|
|
|
|
|
|
|
| 1343 |
if hidden and shared_cells:
|
| 1344 |
# β BOTH WIRES, AND THE SHARED STRATUM IS A THIRD ONE. `strip_row` above cleaned the
|
| 1345 |
# DEFINITION rows; these cells arrived by a different door and one narrowing cannot speak
|
|
@@ -2111,6 +2114,7 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
|
|
| 2111 |
# so a tenant using `json` for a short config sees no change at all.
|
| 2112 |
rows = aios_grid.rows_from_pool(
|
| 2113 |
g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
|
|
|
| 2114 |
# ββ WAVE-34 (R13) β the per-cell enrichment STATE rides the row, beside `_created`/`lat`/
|
| 2115 |
# `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`.
|
| 2116 |
_stamp_ai_states(table_key, g["fields"], rows, st=_lent)
|
|
|
|
| 1340 |
# ββ W36-T21 / R6 β the READ half, in `grid_assembly`'s own position: after `workspace_wire`,
|
| 1341 |
# so the closure covers this user's `custom_` and `measure_` columns too.
|
| 1342 |
fields, rows_src, hidden = _ut_field_wall(session, table_key, fields, rows_src, st=st)
|
| 1343 |
+
# The canonical Zone field is derived from the same table-scoped store as the map DTO, so
|
| 1344 |
+
# FilterBuilder and saved Views use their ordinary multiselect path rather than a map-only one.
|
| 1345 |
+
fields = _ut().with_zone_field(table_key, fields, st=st)
|
| 1346 |
if hidden and shared_cells:
|
| 1347 |
# β BOTH WIRES, AND THE SHARED STRATUM IS A THIRD ONE. `strip_row` above cleaned the
|
| 1348 |
# DEFINITION rows; these cells arrived by a different door and one narrowing cannot speak
|
|
|
|
| 2114 |
# so a tenant using `json` for a short config sees no change at all.
|
| 2115 |
rows = aios_grid.rows_from_pool(
|
| 2116 |
g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
| 2117 |
+
rows = _ut().zone_cells(table_key, rows, allowed_row_ids=g["pids"], st=_lent)
|
| 2118 |
# ββ WAVE-34 (R13) β the per-cell enrichment STATE rides the row, beside `_created`/`lat`/
|
| 2119 |
# `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`.
|
| 2120 |
_stamp_ai_states(table_key, g["fields"], rows, st=_lent)
|
platform/aios_grid.py
CHANGED
|
@@ -242,6 +242,47 @@ def _clean_options(raw):
|
|
| 242 |
return out[:MAX_FIELD_OPTIONS]
|
| 243 |
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
def _clean_option_colors(raw, options):
|
| 246 |
"""Choice-label -> #RRGGBB, limited to the field's canonical option vocabulary."""
|
| 247 |
if not isinstance(raw, dict):
|
|
@@ -500,7 +541,14 @@ def _clean_geocode(raw):
|
|
| 500 |
# would make an identical record mean different things to two hosts. The host computes the
|
| 501 |
# next occurrence and preview at projection time from this compact, durable shape.
|
| 502 |
_RECURRENCE_KINDS = frozenset({'daily', 'weekly', 'monthly', 'yearly', 'holiday'})
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
|
| 506 |
def _iso_day(value):
|
|
@@ -539,17 +587,63 @@ def _last_weekday(year, month, weekday):
|
|
| 539 |
return day - _dt.timedelta(days=(day.weekday() - weekday) % 7)
|
| 540 |
|
| 541 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
def public_holidays(calendar, year):
|
| 543 |
-
"""
|
| 544 |
|
| 545 |
US federal holidays are intentionally calculated locally rather than fetched from a public
|
| 546 |
endpoint during a render: a recurrence must keep its answer during an outage and historical
|
| 547 |
-
previews must not move when a third party updates a feed.
|
| 548 |
-
|
| 549 |
-
rather than pretending it is the US calendar.
|
| 550 |
"""
|
| 551 |
-
|
|
|
|
| 552 |
return frozenset()
|
|
|
|
|
|
|
| 553 |
days = {
|
| 554 |
_observed(_dt.date(year, 1, 1)),
|
| 555 |
_nth_weekday(year, 1, 0, 3), # Martin Luther King Jr. Day
|
|
@@ -598,10 +692,21 @@ def clean_date_recurrence(raw):
|
|
| 598 |
and 0 <= day <= 6})
|
| 599 |
rule['weekdays'] = weekdays or [start.weekday()]
|
| 600 |
elif kind == 'monthly':
|
| 601 |
-
day
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
elif kind == 'yearly':
|
| 606 |
month, day = raw_rule.get('month', start.month), raw_rule.get('day', start.day)
|
| 607 |
if (not isinstance(month, int) or isinstance(month, bool) or not 1 <= month <= 12
|
|
@@ -615,9 +720,11 @@ def clean_date_recurrence(raw):
|
|
| 615 |
rule.update({'month': month, 'day': day})
|
| 616 |
elif kind == 'holiday':
|
| 617 |
calendar = str(raw_rule.get('calendar') or 'US').strip().upper()
|
| 618 |
-
if not
|
| 619 |
continue
|
| 620 |
-
|
|
|
|
|
|
|
| 621 |
if not isinstance(offset, int) or isinstance(offset, bool) or offset not in (-1, 0, 1):
|
| 622 |
continue
|
| 623 |
rule.update({'calendar': calendar, 'offset': offset})
|
|
@@ -642,7 +749,14 @@ def _recurrence_matches(day, start, rule):
|
|
| 642 |
return ((day - start).days // 7) % interval == 0 and day.weekday() in rule['weekdays']
|
| 643 |
if kind == 'monthly':
|
| 644 |
months = (day.year - start.year) * 12 + day.month - start.month
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
if kind == 'yearly':
|
| 647 |
return (day.year - start.year) % interval == 0 and day.month == rule['month'] and day.day == rule['day']
|
| 648 |
return False
|
|
@@ -667,12 +781,15 @@ def recurrence_preview(raw, today=None, count=None):
|
|
| 667 |
if any(_recurrence_matches(day, start, rule) for rule in ordinary_rules):
|
| 668 |
found.add(day)
|
| 669 |
for rule in holiday_rules:
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
for
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
| 676 |
day += _dt.timedelta(days=1)
|
| 677 |
return [item.isoformat() for item in sorted(found)[:want]]
|
| 678 |
|
|
@@ -923,6 +1040,22 @@ def fields_from_workspace(workspace=None, cohorts=False, scope_key=None, fields_
|
|
| 923 |
field["measure"] = {"key": base["measure"]["key"], "window": window}
|
| 924 |
if isinstance(meta.get("label"), str) and meta["label"].strip():
|
| 925 |
field["label"] = meta["label"][:120]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
out.append(field)
|
| 927 |
for key, field in saved.items():
|
| 928 |
if key in base_keys or not isinstance(field, dict):
|
|
|
|
| 242 |
return out[:MAX_FIELD_OPTIONS]
|
| 243 |
|
| 244 |
|
| 245 |
+
def merge_choice_options(*sources):
|
| 246 |
+
"""One canonical choice vocabulary from ordered option sources.
|
| 247 |
+
|
| 248 |
+
A source-backed column may learn values through an overlay while retaining contract choices.
|
| 249 |
+
Keeping the merge beside `_clean_options` makes the case-folding, 120-character label, and
|
| 250 |
+
`MAX_FIELD_OPTIONS` rules identical on the persistence and payload paths. The first spelling
|
| 251 |
+
wins: a later ``et`` therefore resolves to an existing ``ET`` rather than producing a second
|
| 252 |
+
visibly identical choice.
|
| 253 |
+
"""
|
| 254 |
+
merged = []
|
| 255 |
+
for source in sources:
|
| 256 |
+
merged.extend(list(source or []))
|
| 257 |
+
return _clean_options(merged)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def apply_choice_overrides(fields, overrides):
|
| 261 |
+
"""Return field copies with durable select-family option overrides applied.
|
| 262 |
+
|
| 263 |
+
`overrides` is deliberately only a field-key map, so a product route can use the tenant-wide
|
| 264 |
+
shared schema and a normal table workspace can use its personal source-column annotations.
|
| 265 |
+
It never changes a non-choice field, and it never replaces canonical choices with a stale
|
| 266 |
+
annotation: both vocabularies are merged through the one cleaner above.
|
| 267 |
+
"""
|
| 268 |
+
saved = overrides if isinstance(overrides, dict) else {}
|
| 269 |
+
out = []
|
| 270 |
+
for raw in fields or []:
|
| 271 |
+
field = dict(raw) if isinstance(raw, dict) else raw
|
| 272 |
+
if not isinstance(field, dict) or field.get("type") not in ("select", "multiselect"):
|
| 273 |
+
out.append(field)
|
| 274 |
+
continue
|
| 275 |
+
extra = saved.get(field.get("key"))
|
| 276 |
+
if not isinstance(extra, dict) or "options" not in extra:
|
| 277 |
+
out.append(field)
|
| 278 |
+
continue
|
| 279 |
+
options = merge_choice_options(field.get("options"), extra.get("options"))
|
| 280 |
+
field["options"] = options
|
| 281 |
+
field.update(_choice_appearance(extra, options))
|
| 282 |
+
out.append(field)
|
| 283 |
+
return out
|
| 284 |
+
|
| 285 |
+
|
| 286 |
def _clean_option_colors(raw, options):
|
| 287 |
"""Choice-label -> #RRGGBB, limited to the field's canonical option vocabulary."""
|
| 288 |
if not isinstance(raw, dict):
|
|
|
|
| 541 |
# would make an identical record mean different things to two hosts. The host computes the
|
| 542 |
# next occurrence and preview at projection time from this compact, durable shape.
|
| 543 |
_RECURRENCE_KINDS = frozenset({'daily', 'weekly', 'monthly', 'yearly', 'holiday'})
|
| 544 |
+
# A calendar appears here only when the server really evaluates it. The Date picker reads this
|
| 545 |
+
# same registry, so a selector cannot promise a country whose recurrence becomes a no-op later.
|
| 546 |
+
PUBLIC_HOLIDAY_CALENDARS = (
|
| 547 |
+
{'code': 'US', 'label': 'United States federal holidays'},
|
| 548 |
+
{'code': 'CA', 'label': 'Canada federal public holidays'},
|
| 549 |
+
)
|
| 550 |
+
_PUBLIC_HOLIDAY_CALENDARS = frozenset(item['code'] for item in PUBLIC_HOLIDAY_CALENDARS)
|
| 551 |
+
_HOLIDAY_RELATIONS = {'day_before': -1, 'day_on': 0, 'day_after': 1}
|
| 552 |
|
| 553 |
|
| 554 |
def _iso_day(value):
|
|
|
|
| 587 |
return day - _dt.timedelta(days=(day.weekday() - weekday) % 7)
|
| 588 |
|
| 589 |
|
| 590 |
+
def _easter_sunday(year):
|
| 591 |
+
"""Gregorian computus, kept local so a recurrence preview needs no network."""
|
| 592 |
+
a = year % 19
|
| 593 |
+
b, c = divmod(year, 100)
|
| 594 |
+
d, e = divmod(b, 4)
|
| 595 |
+
f = (b + 8) // 25
|
| 596 |
+
g = (b - f + 1) // 3
|
| 597 |
+
h = (19 * a + b - d - g + 15) % 30
|
| 598 |
+
i, k = divmod(c, 4)
|
| 599 |
+
l = (32 + 2 * e + 2 * i - h - k) % 7
|
| 600 |
+
m = (a + 11 * h + 22 * l) // 451
|
| 601 |
+
month = (h + l - 7 * m + 114) // 31
|
| 602 |
+
day = (h + l - 7 * m + 114) % 31 + 1
|
| 603 |
+
return _dt.date(year, month, day)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def _canada_public_holidays(year):
|
| 607 |
+
"""Canada's federal public-service holiday schedule, deterministically calculated.
|
| 608 |
+
|
| 609 |
+
This is deliberately not a claim about every provincial observance. Weekend fixed holidays
|
| 610 |
+
move to the next available weekday, including the Christmas/Boxing-Day collision.
|
| 611 |
+
"""
|
| 612 |
+
days = {
|
| 613 |
+
_easter_sunday(year) - _dt.timedelta(days=2), # Good Friday
|
| 614 |
+
_last_weekday(year, 5, 0) - _dt.timedelta(days=7), # Monday before May 25
|
| 615 |
+
_nth_weekday(year, 9, 0, 1), # Labour Day
|
| 616 |
+
_nth_weekday(year, 10, 0, 2), # Thanksgiving
|
| 617 |
+
}
|
| 618 |
+
fixed = [_dt.date(year, 1, 1), _dt.date(year, 7, 1),
|
| 619 |
+
_dt.date(year, 11, 11), _dt.date(year, 12, 25), _dt.date(year, 12, 26)]
|
| 620 |
+
if year >= 2021:
|
| 621 |
+
fixed.append(_dt.date(year, 9, 30)) # Truth and Reconciliation
|
| 622 |
+
for holiday in fixed:
|
| 623 |
+
observed = holiday
|
| 624 |
+
if observed.weekday() == 5:
|
| 625 |
+
observed += _dt.timedelta(days=2)
|
| 626 |
+
elif observed.weekday() == 6:
|
| 627 |
+
observed += _dt.timedelta(days=1)
|
| 628 |
+
while observed in days:
|
| 629 |
+
observed += _dt.timedelta(days=1)
|
| 630 |
+
days.add(observed)
|
| 631 |
+
return frozenset(days)
|
| 632 |
+
|
| 633 |
+
|
| 634 |
def public_holidays(calendar, year):
|
| 635 |
+
"""A supported public holiday calendar, returned as deterministic ISO-day objects.
|
| 636 |
|
| 637 |
US federal holidays are intentionally calculated locally rather than fetched from a public
|
| 638 |
endpoint during a render: a recurrence must keep its answer during an outage and historical
|
| 639 |
+
previews must not move when a third party updates a feed. Unsupported calendars are
|
| 640 |
+
refused at the field door; the empty result here protects old malformed stored data.
|
|
|
|
| 641 |
"""
|
| 642 |
+
calendar = str(calendar or '').upper()
|
| 643 |
+
if calendar not in _PUBLIC_HOLIDAY_CALENDARS:
|
| 644 |
return frozenset()
|
| 645 |
+
if calendar == 'CA':
|
| 646 |
+
return _canada_public_holidays(year)
|
| 647 |
days = {
|
| 648 |
_observed(_dt.date(year, 1, 1)),
|
| 649 |
_nth_weekday(year, 1, 0, 3), # Martin Luther King Jr. Day
|
|
|
|
| 692 |
and 0 <= day <= 6})
|
| 693 |
rule['weekdays'] = weekdays or [start.weekday()]
|
| 694 |
elif kind == 'monthly':
|
| 695 |
+
# ``day: 1`` means the first of every N months. ``ordinal`` + ``weekday`` adds
|
| 696 |
+
# Google-Calendar-style first Monday / last Friday without a competing model.
|
| 697 |
+
if 'ordinal' in raw_rule or 'weekday' in raw_rule:
|
| 698 |
+
ordinal, weekday = raw_rule.get('ordinal'), raw_rule.get('weekday')
|
| 699 |
+
if (not isinstance(ordinal, int) or isinstance(ordinal, bool)
|
| 700 |
+
or ordinal not in (-1, 1, 2, 3, 4, 5)
|
| 701 |
+
or not isinstance(weekday, int) or isinstance(weekday, bool)
|
| 702 |
+
or not 0 <= weekday <= 6):
|
| 703 |
+
continue
|
| 704 |
+
rule.update({'ordinal': ordinal, 'weekday': weekday})
|
| 705 |
+
else:
|
| 706 |
+
day = raw_rule.get('day', start.day)
|
| 707 |
+
if not isinstance(day, int) or isinstance(day, bool) or not 1 <= day <= 31:
|
| 708 |
+
continue
|
| 709 |
+
rule['day'] = day
|
| 710 |
elif kind == 'yearly':
|
| 711 |
month, day = raw_rule.get('month', start.month), raw_rule.get('day', start.day)
|
| 712 |
if (not isinstance(month, int) or isinstance(month, bool) or not 1 <= month <= 12
|
|
|
|
| 720 |
rule.update({'month': month, 'day': day})
|
| 721 |
elif kind == 'holiday':
|
| 722 |
calendar = str(raw_rule.get('calendar') or 'US').strip().upper()
|
| 723 |
+
if calendar not in _PUBLIC_HOLIDAY_CALENDARS:
|
| 724 |
continue
|
| 725 |
+
relation = raw_rule.get('relation')
|
| 726 |
+
offset = (_HOLIDAY_RELATIONS.get(str(relation).strip().lower())
|
| 727 |
+
if relation is not None else raw_rule.get('offset', 0))
|
| 728 |
if not isinstance(offset, int) or isinstance(offset, bool) or offset not in (-1, 0, 1):
|
| 729 |
continue
|
| 730 |
rule.update({'calendar': calendar, 'offset': offset})
|
|
|
|
| 749 |
return ((day - start).days // 7) % interval == 0 and day.weekday() in rule['weekdays']
|
| 750 |
if kind == 'monthly':
|
| 751 |
months = (day.year - start.year) * 12 + day.month - start.month
|
| 752 |
+
if months < 0 or months % interval:
|
| 753 |
+
return False
|
| 754 |
+
if 'ordinal' not in rule:
|
| 755 |
+
return day.day == rule['day']
|
| 756 |
+
match = (_last_weekday(day.year, day.month, rule['weekday'])
|
| 757 |
+
if rule['ordinal'] == -1
|
| 758 |
+
else _nth_weekday(day.year, day.month, rule['weekday'], rule['ordinal']))
|
| 759 |
+
return match.month == day.month and match == day
|
| 760 |
if kind == 'yearly':
|
| 761 |
return (day.year - start.year) % interval == 0 and day.month == rule['month'] and day.day == rule['day']
|
| 762 |
return False
|
|
|
|
| 781 |
if any(_recurrence_matches(day, start, rule) for rule in ordinary_rules):
|
| 782 |
found.add(day)
|
| 783 |
for rule in holiday_rules:
|
| 784 |
+
# Day-before next New Year's Day belongs to the following holiday year. Looking at
|
| 785 |
+
# adjacent years makes those boundary dates real rather than silently absent.
|
| 786 |
+
for holiday_year in (day.year - 1, day.year, day.year + 1):
|
| 787 |
+
if holiday_year < start.year or (holiday_year - start.year) % rule['interval']:
|
| 788 |
+
continue
|
| 789 |
+
for holiday in public_holidays(rule['calendar'], holiday_year):
|
| 790 |
+
occurrence = holiday + _dt.timedelta(days=rule['offset'])
|
| 791 |
+
if occurrence == day and occurrence >= start:
|
| 792 |
+
found.add(day)
|
| 793 |
day += _dt.timedelta(days=1)
|
| 794 |
return [item.isoformat() for item in sorted(found)[:want]]
|
| 795 |
|
|
|
|
| 1040 |
field["measure"] = {"key": base["measure"]["key"], "window": window}
|
| 1041 |
if isinstance(meta.get("label"), str) and meta["label"].strip():
|
| 1042 |
field["label"] = meta["label"][:120]
|
| 1043 |
+
# A source-backed choice field can learn vocabulary from accepted values. The annotation
|
| 1044 |
+
# is not a replacement for contract choices: an old workspace cannot make a currently
|
| 1045 |
+
# declared option disappear. This is the read half of TableStore's atomic overlay +
|
| 1046 |
+
# choice write, so an "Add option" survives a fresh payload rather than existing only in
|
| 1047 |
+
# the optimistic cell editor.
|
| 1048 |
+
if base.get("type") in ("select", "multiselect") and "options" in meta:
|
| 1049 |
+
options = merge_choice_options(base.get("options"), meta.get("options"))
|
| 1050 |
+
field["options"] = options
|
| 1051 |
+
field.update(_choice_appearance(meta, options))
|
| 1052 |
+
# Recurrence belongs to the Date FIELD, not to an individual ISO date cell. Source
|
| 1053 |
+
# fields need this explicit read half because their override is otherwise rebuilt from
|
| 1054 |
+
# the immutable contract on every workspace projection.
|
| 1055 |
+
if base.get("type") == "date" and "recurrence" in meta:
|
| 1056 |
+
recurrence = recurrence_payload(meta.get("recurrence"))
|
| 1057 |
+
if recurrence:
|
| 1058 |
+
field["recurrence"] = recurrence
|
| 1059 |
out.append(field)
|
| 1060 |
for key, field in saved.items():
|
| 1061 |
if key in base_keys or not isinstance(field, dict):
|
platform/core/grid_events.py
CHANGED
|
@@ -73,7 +73,8 @@ _DOC_MAX_PER_ROW = 50
|
|
| 73 |
#: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders
|
| 74 |
#: nothing from its own copy of those, and a permissions refusal is answered explicitly.
|
| 75 |
_FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions',
|
| 76 |
-
'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure'
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
class StoreUnavailable(RuntimeError):
|
|
@@ -1236,6 +1237,29 @@ def handle_one(event, ctx):
|
|
| 1236 |
seen.pop(stale, None)
|
| 1237 |
kind = event.get('type')
|
| 1238 |
field_by_key = {f['key']: f for f in fields}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
valid_keys = set(field_by_key)
|
| 1240 |
overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'}
|
| 1241 |
# Event validation is not a render payload. It must not consume a one-shot label
|
|
@@ -1906,6 +1930,22 @@ def handle_one(event, ctx):
|
|
| 1906 |
# with no createdBy is admin-only (fail closed, both directions). The table workspace
|
| 1907 |
# is per-user today, so this wall becomes load-bearing when sharing arrives β but the
|
| 1908 |
# admin path and the legacy path are enforceable (and NC'd) right now.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1909 |
needs_values = False
|
| 1910 |
refused_perms = False
|
| 1911 |
permission_sync = False
|
|
@@ -2851,6 +2891,11 @@ def handle_one(event, ctx):
|
|
| 2851 |
refused = False
|
| 2852 |
stage_moved = False
|
| 2853 |
row_events = [] # C3 (wave 22): admitted writes, emitted AFTER they apply
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2854 |
raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {}
|
| 2855 |
for key, value in raw_updates.items():
|
| 2856 |
if key not in overlay_keys or not isinstance(value, (str, int, float)):
|
|
@@ -3010,32 +3055,49 @@ def handle_one(event, ctx):
|
|
| 3010 |
refused = True
|
| 3011 |
definition = field_by_key.get(key) or {}
|
| 3012 |
if definition.get('type') in ('select', 'multiselect') and clean:
|
| 3013 |
-
|
| 3014 |
-
|
| 3015 |
-
|
| 3016 |
-
|
| 3017 |
-
|
| 3018 |
-
|
| 3019 |
-
|
| 3020 |
-
|
| 3021 |
-
|
| 3022 |
-
|
| 3023 |
-
|
| 3024 |
-
|
| 3025 |
-
|
| 3026 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3027 |
invalid = True
|
| 3028 |
break
|
| 3029 |
-
|
| 3030 |
-
|
| 3031 |
-
|
| 3032 |
-
|
| 3033 |
-
|
| 3034 |
-
|
| 3035 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3036 |
if normalized != clean:
|
| 3037 |
refused = True
|
| 3038 |
clean = normalized
|
|
|
|
|
|
|
| 3039 |
updates[key] = clean
|
| 3040 |
if updates:
|
| 3041 |
if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
|
|
@@ -3045,10 +3107,29 @@ def handle_one(event, ctx):
|
|
| 3045 |
import core.user_tables as _ut_values
|
| 3046 |
_ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st)
|
| 3047 |
elif _store_of(ctx).available():
|
| 3048 |
-
_tops(ctx).
|
|
|
|
| 3049 |
else:
|
| 3050 |
_session_ready()
|
| 3051 |
ws['overlays'].setdefault(str(pid), {}).update(updates)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3052 |
# C3 (wave 22): the admitted writes become ROW EVENTS β emitted AFTER they applied, on
|
| 3053 |
# the ut scopes the event triggers watch this wave, through the seam in user_tables so
|
| 3054 |
# this module never imports the engine. Human door only, which IS the loop law.
|
|
|
|
| 73 |
#: Host-only stamps (createdBy, permissions) are deliberately absent: the client renders
|
| 74 |
#: nothing from its own copy of those, and a permissions refusal is answered explicitly.
|
| 75 |
_FIELD_ECHO_PROPS = ('label', 'type', 'note', 'options', 'colorCodeOptions',
|
| 76 |
+
'optionColors', 'max', 'formula', 'agg', 'scope', 'format', 'measure',
|
| 77 |
+
'recurrence')
|
| 78 |
|
| 79 |
|
| 80 |
class StoreUnavailable(RuntimeError):
|
|
|
|
| 1237 |
seen.pop(stale, None)
|
| 1238 |
kind = event.get('type')
|
| 1239 |
field_by_key = {f['key']: f for f in fields}
|
| 1240 |
+
# A Product shared-select option can have been admitted by an earlier event while this
|
| 1241 |
+
# EventCtx remains alive. The renderer gets the persisted choice projection on refresh,
|
| 1242 |
+
# but this write wall also has to learn it *before* it canonicalises a later paste/API
|
| 1243 |
+
# patch; otherwise ``ET`` followed by ``et`` sees an empty contract again and overwrites
|
| 1244 |
+
# the first spelling. This is intentionally a narrow vocabulary merge, never a second
|
| 1245 |
+
# field-definition reader: visibility, type and permissions remain the caller's served
|
| 1246 |
+
# schema.
|
| 1247 |
+
try:
|
| 1248 |
+
from core import shared_overlay as _shared_choices
|
| 1249 |
+
import aios_grid as _ag_choices
|
| 1250 |
+
_choice_table_key = getattr(getattr(ctx, 'table', None), 'table_key', None)
|
| 1251 |
+
if _choice_table_key:
|
| 1252 |
+
_stored_choices = _shared_choices.fields(_choice_table_key, st=_store_of(ctx)) or {}
|
| 1253 |
+
for _choice_key, _stored_def in _stored_choices.items():
|
| 1254 |
+
_served_def = field_by_key.get(_choice_key)
|
| 1255 |
+
if (_served_def and _served_def.get('type') in ('select', 'multiselect')
|
| 1256 |
+
and isinstance(_stored_def, dict)):
|
| 1257 |
+
_served_def['options'] = _ag_choices.merge_choice_options(
|
| 1258 |
+
_served_def.get('options'), _stored_def.get('options'))
|
| 1259 |
+
except Exception:
|
| 1260 |
+
# Field writes must not become unavailable merely because an optional shared-overlay
|
| 1261 |
+
# vocabulary is absent (ordinary customer and legacy in-memory contexts have none).
|
| 1262 |
+
pass
|
| 1263 |
valid_keys = set(field_by_key)
|
| 1264 |
overlay_keys = {f['key'] for f in fields if f.get('source') == 'overlay'}
|
| 1265 |
# Event validation is not a render payload. It must not consume a one-shot label
|
|
|
|
| 1930 |
# with no createdBy is admin-only (fail closed, both directions). The table workspace
|
| 1931 |
# is per-user today, so this wall becomes load-bearing when sharing arrives β but the
|
| 1932 |
# admin path and the legacy path are enforceable (and NC'd) right now.
|
| 1933 |
+
# W43: recurrence is configuration on a scalar Date field. The DatePicker sends the
|
| 1934 |
+
# complete server-shaped bag; an older field editor that omits it preserves the existing
|
| 1935 |
+
# setting rather than deleting a schedule merely by changing the note.
|
| 1936 |
+
if field.get('type') == 'date':
|
| 1937 |
+
recurrence_raw = raw.get('recurrence') if 'recurrence' in raw else field.get('recurrence')
|
| 1938 |
+
# An omitted recurrence is an older editor changing another Date property, so it
|
| 1939 |
+
# preserves the stored schedule. An explicit JSON null is the DatePicker's clear
|
| 1940 |
+
# command and must not fall through to that preservation branch.
|
| 1941 |
+
if 'recurrence' not in raw and recurrence_raw is None and not shared_field:
|
| 1942 |
+
previous = (ws.get('fields') or {}).get(key)
|
| 1943 |
+
recurrence_raw = previous.get('recurrence') if isinstance(previous, dict) else None
|
| 1944 |
+
recurrence = _ag2.clean_date_recurrence(recurrence_raw)
|
| 1945 |
+
if recurrence:
|
| 1946 |
+
field['recurrence'] = recurrence
|
| 1947 |
+
else:
|
| 1948 |
+
field.pop('recurrence', None)
|
| 1949 |
needs_values = False
|
| 1950 |
refused_perms = False
|
| 1951 |
permission_sync = False
|
|
|
|
| 2891 |
refused = False
|
| 2892 |
stage_moved = False
|
| 2893 |
row_events = [] # C3 (wave 22): admitted writes, emitted AFTER they apply
|
| 2894 |
+
# W43: labels learned from a normal cell edit are persisted with that cell. The actual
|
| 2895 |
+
# residency transaction belongs to TableStore / shared_overlay below; this map carries
|
| 2896 |
+
# only NEW canonical labels, after this wall has applied the same permission, case and cap
|
| 2897 |
+
# rules as every other choice write.
|
| 2898 |
+
choice_additions = {}
|
| 2899 |
raw_updates = event.get('updates') if isinstance(event.get('updates'), dict) else {}
|
| 2900 |
for key, value in raw_updates.items():
|
| 2901 |
if key not in overlay_keys or not isinstance(value, (str, int, float)):
|
|
|
|
| 3055 |
refused = True
|
| 3056 |
definition = field_by_key.get(key) or {}
|
| 3057 |
if definition.get('type') in ('select', 'multiselect') and clean:
|
| 3058 |
+
# A choice value is DATA, not a precondition the field editor must have visited.
|
| 3059 |
+
# Pasted/imported/API values therefore mint an option through this same write
|
| 3060 |
+
# door. It is deliberately still bounded and case-canonical: "ET" then "et"
|
| 3061 |
+
# is one option, and the 51st distinct label is refused rather than creating a
|
| 3062 |
+
# picker the contract cannot represent.
|
| 3063 |
+
import aios_grid as _ag_choices
|
| 3064 |
+
options = _ag_choices.merge_choice_options(definition.get('options'))
|
| 3065 |
+
canonical = {v.strip().casefold(): v for v in options}
|
| 3066 |
+
raw_parts = ([clean] if definition.get('type') == 'select'
|
| 3067 |
+
else clean.split(','))
|
| 3068 |
+
normalized_parts = []
|
| 3069 |
+
additions = []
|
| 3070 |
+
seen = set()
|
| 3071 |
+
invalid = False
|
| 3072 |
+
for part in raw_parts:
|
| 3073 |
+
candidate = _ag_choices._clean_options([part])
|
| 3074 |
+
if len(candidate) != 1:
|
| 3075 |
+
invalid = True
|
| 3076 |
+
break
|
| 3077 |
+
label = candidate[0]
|
| 3078 |
+
folded = label.casefold()
|
| 3079 |
+
match = canonical.get(folded)
|
| 3080 |
+
if match is None:
|
| 3081 |
+
if len(options) >= _ag_choices.MAX_FIELD_OPTIONS:
|
| 3082 |
invalid = True
|
| 3083 |
break
|
| 3084 |
+
match = label
|
| 3085 |
+
options.append(match)
|
| 3086 |
+
canonical[folded] = match
|
| 3087 |
+
additions.append(match)
|
| 3088 |
+
if folded not in seen:
|
| 3089 |
+
seen.add(folded)
|
| 3090 |
+
normalized_parts.append(match)
|
| 3091 |
+
if invalid:
|
| 3092 |
+
refused = True
|
| 3093 |
+
continue
|
| 3094 |
+
normalized = (normalized_parts[0] if definition.get('type') == 'select'
|
| 3095 |
+
else ','.join(normalized_parts))
|
| 3096 |
if normalized != clean:
|
| 3097 |
refused = True
|
| 3098 |
clean = normalized
|
| 3099 |
+
if additions:
|
| 3100 |
+
choice_additions[key] = additions
|
| 3101 |
updates[key] = clean
|
| 3102 |
if updates:
|
| 3103 |
if str(ctx.scope_key or '').startswith('ut_') and ctx.table is not None:
|
|
|
|
| 3107 |
import core.user_tables as _ut_values
|
| 3108 |
_ut_values.patch_cells(ctx.scope_key, pid, updates, st=ctx.table.st)
|
| 3109 |
elif _store_of(ctx).available():
|
| 3110 |
+
_tops(ctx).patch_overlay_with_choice_options(
|
| 3111 |
+
uname, pid, updates, choice_additions)
|
| 3112 |
else:
|
| 3113 |
_session_ready()
|
| 3114 |
ws['overlays'].setdefault(str(pid), {}).update(updates)
|
| 3115 |
+
if choice_additions:
|
| 3116 |
+
import aios_grid as _ag_choices
|
| 3117 |
+
for _key, _values in choice_additions.items():
|
| 3118 |
+
_prior = ws['fields'].get(_key)
|
| 3119 |
+
_entry = dict(_prior) if isinstance(_prior, dict) else {}
|
| 3120 |
+
_entry['options'] = _ag_choices.merge_choice_options(
|
| 3121 |
+
_entry.get('options'), _values)
|
| 3122 |
+
ws['fields'][_key] = _entry
|
| 3123 |
+
# Keep this long-lived event context in sync too. The following event may be a
|
| 3124 |
+
# paste/API retry before the next field projection is fetched, and it must retain
|
| 3125 |
+
# the original option's casing rather than treating it as another new choice.
|
| 3126 |
+
if choice_additions:
|
| 3127 |
+
import aios_grid as _ag_choices
|
| 3128 |
+
for _key, _values in choice_additions.items():
|
| 3129 |
+
_definition = field_by_key.get(_key)
|
| 3130 |
+
if _definition is not None:
|
| 3131 |
+
_definition['options'] = _ag_choices.merge_choice_options(
|
| 3132 |
+
_definition.get('options'), _values)
|
| 3133 |
# C3 (wave 22): the admitted writes become ROW EVENTS β emitted AFTER they applied, on
|
| 3134 |
# the ut scopes the event triggers watch this wave, through the seam in user_tables so
|
| 3135 |
# this module never imports the engine. Human door only, which IS the loop law.
|
platform/core/shared_overlay.py
CHANGED
|
@@ -101,7 +101,7 @@ ALLOWED_FIELD_KEYS = frozenset({
|
|
| 101 |
'note', 'description', '_note',
|
| 102 |
# display and behaviour
|
| 103 |
'format', 'agg', 'options', 'optionColors', 'colorCodeOptions', 'default', 'pinned',
|
| 104 |
-
'filterable', 'multi', 'max', 'derived', 'preset', 'custom', 'profile', 'link',
|
| 105 |
# the created strata's computation bags
|
| 106 |
'formula', 'measure', 'metric', 'rollup', 'geocode', 'code', 'automation', 'aiEnrich',
|
| 107 |
'scope',
|
|
@@ -449,6 +449,39 @@ def put_cells(table_key, pid, values, st=None):
|
|
| 449 |
return clean
|
| 450 |
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
def put_rows(table_key, rows, st=None):
|
| 453 |
"""Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
|
| 454 |
|
|
|
|
| 101 |
'note', 'description', '_note',
|
| 102 |
# display and behaviour
|
| 103 |
'format', 'agg', 'options', 'optionColors', 'colorCodeOptions', 'default', 'pinned',
|
| 104 |
+
'filterable', 'multi', 'max', 'derived', 'preset', 'custom', 'profile', 'link', 'recurrence',
|
| 105 |
# the created strata's computation bags
|
| 106 |
'formula', 'measure', 'metric', 'rollup', 'geocode', 'code', 'automation', 'aiEnrich',
|
| 107 |
'scope',
|
|
|
|
| 449 |
return clean
|
| 450 |
|
| 451 |
|
| 452 |
+
def put_cells_and_choice_options(table_key, pid, values, choice_values, st=None):
|
| 453 |
+
"""Write shared cells plus their newly admitted choice labels in ONE mutation.
|
| 454 |
+
|
| 455 |
+
Product contract columns such as ``sub_category`` have shared values and therefore need their
|
| 456 |
+
learned vocabulary in this same shared schema document. A separate `put_cells` then
|
| 457 |
+
`put_field` sequence could leave a durable value that no fresh picker recognises if the second
|
| 458 |
+
write fails. The caller has already validated the labels; this function only merges them
|
| 459 |
+
atomically with the values using the canonical choice cleaner.
|
| 460 |
+
"""
|
| 461 |
+
row_id = _pid(pid)
|
| 462 |
+
clean = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
|
| 463 |
+
additions = {str(key): list(labels or ()) for key, labels in
|
| 464 |
+
(choice_values or {}).items() if str(key) and labels}
|
| 465 |
+
if not clean:
|
| 466 |
+
return {}
|
| 467 |
+
|
| 468 |
+
def _patch(data):
|
| 469 |
+
data['cells'].setdefault(row_id, {}).update(clean)
|
| 470 |
+
if not additions:
|
| 471 |
+
return
|
| 472 |
+
# `aios_grid` deliberately has no app-internal imports; this core persistence layer can
|
| 473 |
+
# consume its pure normaliser to keep case folding and the 50-option cap identical.
|
| 474 |
+
from aios_grid import merge_choice_options
|
| 475 |
+
for key, labels in additions.items():
|
| 476 |
+
prior = data['fields'].get(key)
|
| 477 |
+
entry = dict(prior) if isinstance(prior, dict) else {'key': key}
|
| 478 |
+
entry['options'] = merge_choice_options(entry.get('options'), labels)
|
| 479 |
+
data['fields'][key] = entry
|
| 480 |
+
|
| 481 |
+
_write(table_key, _patch, st)
|
| 482 |
+
return clean
|
| 483 |
+
|
| 484 |
+
|
| 485 |
def put_rows(table_key, rows, st=None):
|
| 486 |
"""Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
|
| 487 |
|
platform/core/table_store.py
CHANGED
|
@@ -189,7 +189,15 @@ def source_override_is_empty(payload):
|
|
| 189 |
return (not str(payload.get('note') or '').strip()
|
| 190 |
and not isinstance(payload.get('measure'), dict)
|
| 191 |
and not isinstance(payload.get('format'), dict)
|
| 192 |
-
and not str(payload.get('agg') or '').strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120):
|
|
@@ -1138,6 +1146,36 @@ class TableStore:
|
|
| 1138 |
|
| 1139 |
self._update(username, _patch)
|
| 1140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1141 |
|
| 1142 |
def make(table_key, st=None):
|
| 1143 |
return TableStore(table_key, st=st)
|
|
|
|
| 189 |
return (not str(payload.get('note') or '').strip()
|
| 190 |
and not isinstance(payload.get('measure'), dict)
|
| 191 |
and not isinstance(payload.get('format'), dict)
|
| 192 |
+
and not str(payload.get('agg') or '').strip()
|
| 193 |
+
# W43: a learned source-column vocabulary is durable field state. Dropping an
|
| 194 |
+
# otherwise-empty override here made a successfully added option disappear on the
|
| 195 |
+
# next payload, which is indistinguishable from an input that never saved.
|
| 196 |
+
and not isinstance(payload.get('options'), list)
|
| 197 |
+
# A source Date field owns recurrence metadata. It is durable configuration just
|
| 198 |
+
# like display format, not a generated cell value, so a note-less field must retain
|
| 199 |
+
# the override that carries it.
|
| 200 |
+
and not isinstance(payload.get('recurrence'), dict))
|
| 201 |
|
| 202 |
|
| 203 |
def _unique_name(wanted, existing, *, fallback='Untitled', max_len=120):
|
|
|
|
| 1146 |
|
| 1147 |
self._update(username, _patch)
|
| 1148 |
|
| 1149 |
+
def patch_overlay_with_choice_options(self, username, pid, updates, choice_values):
|
| 1150 |
+
"""Persist cell updates and newly admitted choice labels in one workspace update.
|
| 1151 |
+
|
| 1152 |
+
The event wall has already checked field visibility, edit permission, label size, and the
|
| 1153 |
+
50-choice cap. This method owns only residency and atomicity: an overlay value must never
|
| 1154 |
+
commit while the field vocabulary remains stale, because a refresh would render a cell the
|
| 1155 |
+
picker cannot select.
|
| 1156 |
+
"""
|
| 1157 |
+
clean = dict(updates or {})
|
| 1158 |
+
additions = {str(key): list(values or ()) for key, values in
|
| 1159 |
+
(choice_values or {}).items() if str(key) and values}
|
| 1160 |
+
if not clean:
|
| 1161 |
+
return
|
| 1162 |
+
|
| 1163 |
+
def _patch(ws):
|
| 1164 |
+
ws['overlays'].setdefault(str(int(pid)), {}).update(clean)
|
| 1165 |
+
if not additions:
|
| 1166 |
+
return
|
| 1167 |
+
# Import here rather than at module load: `aios_grid` is intentionally independent
|
| 1168 |
+
# of `core`, while this lower persistence layer may use its pure canonical cleaner.
|
| 1169 |
+
from aios_grid import merge_choice_options
|
| 1170 |
+
fields = ws.setdefault('fields', {})
|
| 1171 |
+
for key, values in additions.items():
|
| 1172 |
+
prior = fields.get(key)
|
| 1173 |
+
entry = dict(prior) if isinstance(prior, dict) else {}
|
| 1174 |
+
entry['options'] = merge_choice_options(entry.get('options'), values)
|
| 1175 |
+
fields[key] = entry
|
| 1176 |
+
|
| 1177 |
+
self._update(username, _patch)
|
| 1178 |
+
|
| 1179 |
|
| 1180 |
def make(table_key, st=None):
|
| 1181 |
return TableStore(table_key, st=st)
|
platform/core/user_tables.py
CHANGED
|
@@ -55,6 +55,9 @@ import core.store as store
|
|
| 55 |
|
| 56 |
STORE_KEY = 'user_tables'
|
| 57 |
ZONE_STORE_KEY = 'table_zones'
|
|
|
|
|
|
|
|
|
|
| 58 |
MAX_TABLES = 40
|
| 59 |
MAX_LABEL = 60
|
| 60 |
|
|
@@ -1198,7 +1201,9 @@ def clean_zone(raw, field_keys=None, allowed_row_ids=None):
|
|
| 1198 |
"""One exact durable Zone shape, or ``None``. Unknown keys never reach the store."""
|
| 1199 |
if not isinstance(raw, dict):
|
| 1200 |
return None
|
| 1201 |
-
|
|
|
|
|
|
|
| 1202 |
geometry = _clean_zone_geometry(raw.get('geometryGeoJson'))
|
| 1203 |
if not name or geometry is None:
|
| 1204 |
return None
|
|
@@ -1229,6 +1234,12 @@ def clean_zone(raw, field_keys=None, allowed_row_ids=None):
|
|
| 1229 |
seen.add(pid)
|
| 1230 |
memberships.append(pid)
|
| 1231 |
out['memberships'] = memberships
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1232 |
return out
|
| 1233 |
|
| 1234 |
|
|
@@ -1249,7 +1260,8 @@ def zones(table_key, st=None):
|
|
| 1249 |
return out
|
| 1250 |
|
| 1251 |
|
| 1252 |
-
def upsert_zone(table_key, raw, field_keys=None, allowed_row_ids=None, st=None
|
|
|
|
| 1253 |
"""Create/update a zone by id; the returned object is the exact persisted wire shape."""
|
| 1254 |
key = str(table_key or '').strip()
|
| 1255 |
if not key:
|
|
@@ -1267,22 +1279,69 @@ def upsert_zone(table_key, raw, field_keys=None, allowed_row_ids=None, st=None):
|
|
| 1267 |
next_zones = []
|
| 1268 |
for zone in existing[:200]:
|
| 1269 |
if isinstance(zone, dict) and zone.get('id') == clean['id']:
|
| 1270 |
-
next_zones.append(
|
|
|
|
|
|
|
| 1271 |
replaced = True
|
| 1272 |
else:
|
| 1273 |
next_zones.append(zone)
|
| 1274 |
if not replaced:
|
| 1275 |
if len(next_zones) >= 200:
|
| 1276 |
return current
|
| 1277 |
-
next_zones.append(
|
|
|
|
|
|
|
| 1278 |
bucket[key] = next_zones
|
| 1279 |
-
|
|
|
|
|
|
|
| 1280 |
return bucket
|
| 1281 |
|
| 1282 |
_st(st).update(ZONE_STORE_KEY, _save, flush='sync')
|
| 1283 |
return result or None
|
| 1284 |
|
| 1285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1286 |
def delete_zone(table_key, zone_id, st=None):
|
| 1287 |
"""Delete only this named zone; no prefix/glob deletion exists in this contract."""
|
| 1288 |
key, zid = str(table_key or '').strip(), str(zone_id or '').strip()
|
|
|
|
| 55 |
|
| 56 |
STORE_KEY = 'user_tables'
|
| 57 |
ZONE_STORE_KEY = 'table_zones'
|
| 58 |
+
# A reserved, derived multi-select field. It is table data backed by durable Zone membership,
|
| 59 |
+
# not a user-editable column and therefore cannot collide with a field a user creates.
|
| 60 |
+
ZONE_FIELD_KEY = '__zone__'
|
| 61 |
MAX_TABLES = 40
|
| 62 |
MAX_LABEL = 60
|
| 63 |
|
|
|
|
| 1201 |
"""One exact durable Zone shape, or ``None``. Unknown keys never reach the store."""
|
| 1202 |
if not isinstance(raw, dict):
|
| 1203 |
return None
|
| 1204 |
+
# A multiselect cell is comma-delimited across the whole grid/filter contract, so commas
|
| 1205 |
+
# cannot be legal inside one Zone label without making one Zone appear as two filter choices.
|
| 1206 |
+
name = ' '.join(str(raw.get('name') or '').replace(',', ' ').split())[:120]
|
| 1207 |
geometry = _clean_zone_geometry(raw.get('geometryGeoJson'))
|
| 1208 |
if not name or geometry is None:
|
| 1209 |
return None
|
|
|
|
| 1234 |
seen.add(pid)
|
| 1235 |
memberships.append(pid)
|
| 1236 |
out['memberships'] = memberships
|
| 1237 |
+
creator = str(raw.get('createdBy') or '').strip()[:120]
|
| 1238 |
+
created_at = str(raw.get('createdAt') or '').strip()[:64]
|
| 1239 |
+
if creator:
|
| 1240 |
+
out['createdBy'] = creator
|
| 1241 |
+
if created_at:
|
| 1242 |
+
out['createdAt'] = created_at
|
| 1243 |
return out
|
| 1244 |
|
| 1245 |
|
|
|
|
| 1260 |
return out
|
| 1261 |
|
| 1262 |
|
| 1263 |
+
def upsert_zone(table_key, raw, field_keys=None, allowed_row_ids=None, st=None,
|
| 1264 |
+
created_by=None):
|
| 1265 |
"""Create/update a zone by id; the returned object is the exact persisted wire shape."""
|
| 1266 |
key = str(table_key or '').strip()
|
| 1267 |
if not key:
|
|
|
|
| 1279 |
next_zones = []
|
| 1280 |
for zone in existing[:200]:
|
| 1281 |
if isinstance(zone, dict) and zone.get('id') == clean['id']:
|
| 1282 |
+
next_zones.append({**clean,
|
| 1283 |
+
'createdBy': zone.get('createdBy') or str(created_by or ''),
|
| 1284 |
+
'createdAt': zone.get('createdAt') or _dt.datetime.utcnow().isoformat()})
|
| 1285 |
replaced = True
|
| 1286 |
else:
|
| 1287 |
next_zones.append(zone)
|
| 1288 |
if not replaced:
|
| 1289 |
if len(next_zones) >= 200:
|
| 1290 |
return current
|
| 1291 |
+
next_zones.append({**clean,
|
| 1292 |
+
'createdBy': str(created_by or ''),
|
| 1293 |
+
'createdAt': _dt.datetime.utcnow().isoformat()})
|
| 1294 |
bucket[key] = next_zones
|
| 1295 |
+
persisted = next((zone for zone in next_zones
|
| 1296 |
+
if isinstance(zone, dict) and zone.get('id') == clean['id']), clean)
|
| 1297 |
+
result.update(persisted)
|
| 1298 |
return bucket
|
| 1299 |
|
| 1300 |
_st(st).update(ZONE_STORE_KEY, _save, flush='sync')
|
| 1301 |
return result or None
|
| 1302 |
|
| 1303 |
|
| 1304 |
+
def zone_field(table_key, st=None):
|
| 1305 |
+
"""The canonical derived multi-select Field for one table's durable Zones."""
|
| 1306 |
+
names, seen = [], set()
|
| 1307 |
+
for zone in zones(table_key, st=st):
|
| 1308 |
+
name = str(zone.get('name') or '').strip()
|
| 1309 |
+
if name and name not in seen:
|
| 1310 |
+
names.append(name)
|
| 1311 |
+
seen.add(name)
|
| 1312 |
+
return {'key': ZONE_FIELD_KEY, 'label': 'Zone', 'type': 'multiselect', 'source': 'odoo',
|
| 1313 |
+
'derived': True, 'multi': True, 'default': True, 'options': names,
|
| 1314 |
+
'colorCodeOptions': True}
|
| 1315 |
+
|
| 1316 |
+
|
| 1317 |
+
def with_zone_field(table_key, fields, st=None):
|
| 1318 |
+
"""Append the table's derived Zone field once, without mutating a shared field list."""
|
| 1319 |
+
base = [dict(field) if isinstance(field, dict) else field for field in (fields or ())
|
| 1320 |
+
if not (isinstance(field, dict) and field.get('key') == ZONE_FIELD_KEY)]
|
| 1321 |
+
return base + [zone_field(table_key, st=st)]
|
| 1322 |
+
|
| 1323 |
+
|
| 1324 |
+
def zone_cells(table_key, rows, allowed_row_ids=None, st=None):
|
| 1325 |
+
"""Layer canonical Zone memberships into ordinary row cells for FilterBuilder/evaluator use."""
|
| 1326 |
+
allowed = ({str(pid) for pid in allowed_row_ids} if allowed_row_ids is not None else None)
|
| 1327 |
+
labels_by_pid = {}
|
| 1328 |
+
for zone in zones(table_key, st=st):
|
| 1329 |
+
name = str(zone.get('name') or '').strip()
|
| 1330 |
+
if not name:
|
| 1331 |
+
continue
|
| 1332 |
+
for pid in zone.get('memberships') or ():
|
| 1333 |
+
key = str(pid)
|
| 1334 |
+
if allowed is not None and key not in allowed:
|
| 1335 |
+
continue
|
| 1336 |
+
labels_by_pid.setdefault(key, []).append(name)
|
| 1337 |
+
out = []
|
| 1338 |
+
for row in rows or ():
|
| 1339 |
+
item = dict(row)
|
| 1340 |
+
item[ZONE_FIELD_KEY] = ', '.join(labels_by_pid.get(str(item.get('pid')), ()))
|
| 1341 |
+
out.append(item)
|
| 1342 |
+
return out
|
| 1343 |
+
|
| 1344 |
+
|
| 1345 |
def delete_zone(table_key, zone_id, st=None):
|
| 1346 |
"""Delete only this named zone; no prefix/glob deletion exists in this contract."""
|
| 1347 |
key, zid = str(table_key or '').strip(), str(zone_id or '').strip()
|
platform/modules/product_data.py
CHANGED
|
@@ -288,6 +288,32 @@ class _ProductTableStore(table_store.TableStore):
|
|
| 288 |
if personal:
|
| 289 |
super().patch_overlay(username, pid, personal)
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
TABLE_OPS = _ProductTableStore(TABLE_KEY)
|
| 293 |
|
|
|
|
| 288 |
if personal:
|
| 289 |
super().patch_overlay(username, pid, personal)
|
| 290 |
|
| 291 |
+
def patch_overlay_with_choice_options(self, username, pid, updates, choice_values):
|
| 292 |
+
"""Keep a Product cell and its select vocabulary in the same residency transaction.
|
| 293 |
+
|
| 294 |
+
`sub_category` is a contract-declared shared overlay column. Its values cannot go into a
|
| 295 |
+
person's workspace, and its options cannot either: a colleague must see the newly added
|
| 296 |
+
option after refreshing. Personal Product fields still use TableStore's one-workspace
|
| 297 |
+
transaction through the parent implementation.
|
| 298 |
+
"""
|
| 299 |
+
clean = dict(updates or {})
|
| 300 |
+
additions = {str(key): list(values or ()) for key, values in
|
| 301 |
+
(choice_values or {}).items() if str(key) and values}
|
| 302 |
+
if not clean:
|
| 303 |
+
return
|
| 304 |
+
keys = set(SHARED_KEYS())
|
| 305 |
+
keys.update(shared_overlay.fields(self.table_key, st=self.st) or {})
|
| 306 |
+
shared = {k: v for k, v in clean.items() if k in keys}
|
| 307 |
+
personal = {k: v for k, v in clean.items() if k not in keys}
|
| 308 |
+
shared_choices = {k: v for k, v in additions.items() if k in keys}
|
| 309 |
+
personal_choices = {k: v for k, v in additions.items() if k not in keys}
|
| 310 |
+
if shared:
|
| 311 |
+
shared_overlay.put_cells_and_choice_options(
|
| 312 |
+
TABLE_KEY, pid, shared, shared_choices, st=self.st)
|
| 313 |
+
if personal:
|
| 314 |
+
super().patch_overlay_with_choice_options(
|
| 315 |
+
username, pid, personal, personal_choices)
|
| 316 |
+
|
| 317 |
|
| 318 |
TABLE_OPS = _ProductTableStore(TABLE_KEY)
|
| 319 |
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -100,7 +100,7 @@ interface FieldConfigExtra {
|
|
| 100 |
geocode?: { addressField: string; country?: string };
|
| 101 |
/** β 2026-08-07 β a `link` column's target. REQUIRED for the type: `_clean_field` returns
|
| 102 |
* None for a link with no bag, so a create without this is a column that never appears. */
|
| 103 |
-
link?: { table: string; single?: boolean };
|
| 104 |
/** β 2026-08-07 β a `rollup` column's aggregate. Same requirement, same reason.
|
| 105 |
* β 2026-08-09 β or, INSTEAD, a `source` binding: the read-through kind, which summarises
|
| 106 |
* rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
|
|
@@ -1242,6 +1242,8 @@ function ExtraTypeEditor({
|
|
| 1242 |
onLinkTable,
|
| 1243 |
linkSingle = false,
|
| 1244 |
onLinkSingle,
|
|
|
|
|
|
|
| 1245 |
rollupMode = "link",
|
| 1246 |
onRollupMode,
|
| 1247 |
rollupSourceOffer = { topics: [], windows: [] },
|
|
@@ -1308,6 +1310,8 @@ function ExtraTypeEditor({
|
|
| 1308 |
onLinkTable?: (v: string) => void;
|
| 1309 |
linkSingle?: boolean;
|
| 1310 |
onLinkSingle?: (v: boolean) => void;
|
|
|
|
|
|
|
| 1311 |
/** β 2026-08-09 β which KIND of rollup is being built. `"link"` folds workspace rows;
|
| 1312 |
* `"source"` reads through to a governed Odoo topic without copying a row into the store. */
|
| 1313 |
rollupMode?: "link" | "source";
|
|
@@ -1678,11 +1682,28 @@ function ExtraTypeEditor({
|
|
| 1678 |
There is no other database to link to yet. Create one first, then come back.
|
| 1679 |
</div>
|
| 1680 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1681 |
<label className="cg-cond-line">
|
| 1682 |
<input
|
| 1683 |
type="checkbox"
|
| 1684 |
checked={linkSingle}
|
| 1685 |
aria-label="Allow only one linked record"
|
|
|
|
| 1686 |
onChange={(event) => onLinkSingle?.(event.target.checked)}
|
| 1687 |
/>
|
| 1688 |
<span>Allow only one linked record</span>
|
|
@@ -2237,6 +2258,7 @@ export default function ColumnMenu({
|
|
| 2237 |
*/
|
| 2238 |
const [linkTable, setLinkTable] = useState("");
|
| 2239 |
const [linkSingle, setLinkSingle] = useState(false);
|
|
|
|
| 2240 |
/** β 2026-08-09 β the read-through half. `rollupMode` decides which bag `extraFor` builds;
|
| 2241 |
* the two are mutually exclusive because the host's `_clean_rollup` returns the `source`
|
| 2242 |
* shape before it ever reads `link`. */
|
|
@@ -2355,6 +2377,9 @@ export default function ColumnMenu({
|
|
| 2355 |
});
|
| 2356 |
const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
|
| 2357 |
const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
|
|
|
|
|
|
|
|
|
|
| 2358 |
|
| 2359 |
const swapToNewType = swapTo.startsWith(NEW_PREFIX)
|
| 2360 |
? (swapTo.slice(NEW_PREFIX.length) as FieldType)
|
|
@@ -2512,7 +2537,8 @@ export default function ColumnMenu({
|
|
| 2512 |
// picks records). The DERIVED mode is the automation's, spawned with its join declared β
|
| 2513 |
// offering "join on a column of the other table" here would be a second, harder mental
|
| 2514 |
// model for the same button.
|
| 2515 |
-
if (t === "link") return { link: { table: linkTable,
|
|
|
|
| 2516 |
if (t === "rollup" && rollupMode === "source")
|
| 2517 |
// β THE SOURCE BAG TRAVELS ALONE. `_clean_rollup` reads `source` first and returns
|
| 2518 |
// immediately, so sending `link`/`fn` alongside it would ship keys the store drops β
|
|
@@ -2748,7 +2774,8 @@ export default function ColumnMenu({
|
|
| 2748 |
(field.type === "rollup"
|
| 2749 |
? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
|
| 2750 |
: editLinkTable !== (field.link?.table ?? "") ||
|
| 2751 |
-
editLinkSingle !== (field.link?.single === true)
|
|
|
|
| 2752 |
const canSaveEdit =
|
| 2753 |
/**
|
| 2754 |
* β W41-T31 β THE DESCRIPTION-ONLY PANE'S WHOLE SAVE CONDITION, and it is one dirty flag
|
|
@@ -2821,6 +2848,7 @@ export default function ColumnMenu({
|
|
| 2821 |
...(field.type === "rollup"
|
| 2822 |
? { rollup: editRollup as Record<string, unknown> }
|
| 2823 |
: { link: { table: editLinkTable,
|
|
|
|
| 2824 |
...(editLinkSingle ? { single: true } : {}) } }),
|
| 2825 |
});
|
| 2826 |
if (editNoteDirty) onNote(note);
|
|
@@ -3179,6 +3207,8 @@ export default function ColumnMenu({
|
|
| 3179 |
onLinkTable={setLinkTable}
|
| 3180 |
linkSingle={linkSingle}
|
| 3181 |
onLinkSingle={setLinkSingle}
|
|
|
|
|
|
|
| 3182 |
rollupMode={rollupMode}
|
| 3183 |
onRollupMode={setRollupMode}
|
| 3184 |
rollupSourceOffer={rollupSourceOffer}
|
|
@@ -3488,6 +3518,8 @@ export default function ColumnMenu({
|
|
| 3488 |
onLinkTable={setEditLinkTable}
|
| 3489 |
linkSingle={editLinkSingle}
|
| 3490 |
onLinkSingle={setEditLinkSingle}
|
|
|
|
|
|
|
| 3491 |
rollupSourceOffer={rollupSourceOffer}
|
| 3492 |
rollupMode={editRollup.source ? "source" : "link"}
|
| 3493 |
// β A MODE SWITCH REPLACES THE BAG, it does not merge into it. `_clean_rollup`
|
|
|
|
| 100 |
geocode?: { addressField: string; country?: string };
|
| 101 |
/** β 2026-08-07 β a `link` column's target. REQUIRED for the type: `_clean_field` returns
|
| 102 |
* None for a link with no bag, so a create without this is a column that never appears. */
|
| 103 |
+
link?: { table: string; single?: boolean; selectionMode?: "manual" | "automatic" };
|
| 104 |
/** β 2026-08-07 β a `rollup` column's aggregate. Same requirement, same reason.
|
| 105 |
* β 2026-08-09 β or, INSTEAD, a `source` binding: the read-through kind, which summarises
|
| 106 |
* rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
|
|
|
|
| 1242 |
onLinkTable,
|
| 1243 |
linkSingle = false,
|
| 1244 |
onLinkSingle,
|
| 1245 |
+
linkSelectionMode = "manual",
|
| 1246 |
+
onLinkSelectionMode,
|
| 1247 |
rollupMode = "link",
|
| 1248 |
onRollupMode,
|
| 1249 |
rollupSourceOffer = { topics: [], windows: [] },
|
|
|
|
| 1310 |
onLinkTable?: (v: string) => void;
|
| 1311 |
linkSingle?: boolean;
|
| 1312 |
onLinkSingle?: (v: boolean) => void;
|
| 1313 |
+
linkSelectionMode?: "manual" | "automatic";
|
| 1314 |
+
onLinkSelectionMode?: (v: "manual" | "automatic") => void;
|
| 1315 |
/** β 2026-08-09 β which KIND of rollup is being built. `"link"` folds workspace rows;
|
| 1316 |
* `"source"` reads through to a governed Odoo topic without copying a row into the store. */
|
| 1317 |
rollupMode?: "link" | "source";
|
|
|
|
| 1682 |
There is no other database to link to yet. Create one first, then come back.
|
| 1683 |
</div>
|
| 1684 |
) : null}
|
| 1685 |
+
<label>
|
| 1686 |
+
<span>How links are chosen</span>
|
| 1687 |
+
<select
|
| 1688 |
+
id={`${idPrefix}-link-selection-mode`}
|
| 1689 |
+
className="cg-input"
|
| 1690 |
+
value={linkSelectionMode}
|
| 1691 |
+
aria-label="How linked records are selected"
|
| 1692 |
+
onPointerDown={(event) => event.stopPropagation()}
|
| 1693 |
+
onChange={(event) => onLinkSelectionMode?.(
|
| 1694 |
+
event.target.value === "automatic" ? "automatic" : "manual"
|
| 1695 |
+
)}
|
| 1696 |
+
>
|
| 1697 |
+
<option value="manual">Nothing, I will pick the records myself</option>
|
| 1698 |
+
<option value="automatic">Set links automatically</option>
|
| 1699 |
+
</select>
|
| 1700 |
+
</label>
|
| 1701 |
<label className="cg-cond-line">
|
| 1702 |
<input
|
| 1703 |
type="checkbox"
|
| 1704 |
checked={linkSingle}
|
| 1705 |
aria-label="Allow only one linked record"
|
| 1706 |
+
onPointerDown={(event) => event.stopPropagation()}
|
| 1707 |
onChange={(event) => onLinkSingle?.(event.target.checked)}
|
| 1708 |
/>
|
| 1709 |
<span>Allow only one linked record</span>
|
|
|
|
| 2258 |
*/
|
| 2259 |
const [linkTable, setLinkTable] = useState("");
|
| 2260 |
const [linkSingle, setLinkSingle] = useState(false);
|
| 2261 |
+
const [linkSelectionMode, setLinkSelectionMode] = useState<"manual" | "automatic">("manual");
|
| 2262 |
/** β 2026-08-09 β the read-through half. `rollupMode` decides which bag `extraFor` builds;
|
| 2263 |
* the two are mutually exclusive because the host's `_clean_rollup` returns the `source`
|
| 2264 |
* shape before it ever reads `link`. */
|
|
|
|
| 2377 |
});
|
| 2378 |
const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
|
| 2379 |
const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
|
| 2380 |
+
const [editLinkSelectionMode, setEditLinkSelectionMode] = useState<"manual" | "automatic">(
|
| 2381 |
+
() => field.link?.selectionMode === "automatic" ? "automatic" : "manual"
|
| 2382 |
+
);
|
| 2383 |
|
| 2384 |
const swapToNewType = swapTo.startsWith(NEW_PREFIX)
|
| 2385 |
? (swapTo.slice(NEW_PREFIX.length) as FieldType)
|
|
|
|
| 2537 |
// picks records). The DERIVED mode is the automation's, spawned with its join declared β
|
| 2538 |
// offering "join on a column of the other table" here would be a second, harder mental
|
| 2539 |
// model for the same button.
|
| 2540 |
+
if (t === "link") return { link: { table: linkTable, selectionMode: linkSelectionMode,
|
| 2541 |
+
...(linkSingle ? { single: true } : {}) } };
|
| 2542 |
if (t === "rollup" && rollupMode === "source")
|
| 2543 |
// β THE SOURCE BAG TRAVELS ALONE. `_clean_rollup` reads `source` first and returns
|
| 2544 |
// immediately, so sending `link`/`fn` alongside it would ship keys the store drops β
|
|
|
|
| 2774 |
(field.type === "rollup"
|
| 2775 |
? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
|
| 2776 |
: editLinkTable !== (field.link?.table ?? "") ||
|
| 2777 |
+
editLinkSingle !== (field.link?.single === true) ||
|
| 2778 |
+
editLinkSelectionMode !== (field.link?.selectionMode === "automatic" ? "automatic" : "manual"));
|
| 2779 |
const canSaveEdit =
|
| 2780 |
/**
|
| 2781 |
* β W41-T31 β THE DESCRIPTION-ONLY PANE'S WHOLE SAVE CONDITION, and it is one dirty flag
|
|
|
|
| 2848 |
...(field.type === "rollup"
|
| 2849 |
? { rollup: editRollup as Record<string, unknown> }
|
| 2850 |
: { link: { table: editLinkTable,
|
| 2851 |
+
selectionMode: editLinkSelectionMode,
|
| 2852 |
...(editLinkSingle ? { single: true } : {}) } }),
|
| 2853 |
});
|
| 2854 |
if (editNoteDirty) onNote(note);
|
|
|
|
| 3207 |
onLinkTable={setLinkTable}
|
| 3208 |
linkSingle={linkSingle}
|
| 3209 |
onLinkSingle={setLinkSingle}
|
| 3210 |
+
linkSelectionMode={linkSelectionMode}
|
| 3211 |
+
onLinkSelectionMode={setLinkSelectionMode}
|
| 3212 |
rollupMode={rollupMode}
|
| 3213 |
onRollupMode={setRollupMode}
|
| 3214 |
rollupSourceOffer={rollupSourceOffer}
|
|
|
|
| 3518 |
onLinkTable={setEditLinkTable}
|
| 3519 |
linkSingle={editLinkSingle}
|
| 3520 |
onLinkSingle={setEditLinkSingle}
|
| 3521 |
+
linkSelectionMode={editLinkSelectionMode}
|
| 3522 |
+
onLinkSelectionMode={setEditLinkSelectionMode}
|
| 3523 |
rollupSourceOffer={rollupSourceOffer}
|
| 3524 |
rollupMode={editRollup.source ? "source" : "link"}
|
| 3525 |
// β A MODE SWITCH REPLACES THE BAG, it does not merge into it. `_clean_rollup`
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -94,7 +94,7 @@ import { API_V1, CREDENTIALS, NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT,
|
|
| 94 |
from "../apiContract";
|
| 95 |
import type { ViewOpenDetail } from "../apiContract";
|
| 96 |
import { addTableField, addTableRow, clearTopicRowsCache, deleteRouteOrderField, deleteTableField,
|
| 97 |
-
deleteTableRow, fetchLinkTargets,
|
| 98 |
fetchRollupSources, patchRouteOrderField, patchTableField, enrichField } from "./apiBridge";
|
| 99 |
import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| 100 |
import SelectFromFile from "./SelectFromFile";
|
|
@@ -133,6 +133,7 @@ import { DashboardView } from "../viz/DashboardView";
|
|
| 133 |
import { cleanCharts } from "../viz/chartData";
|
| 134 |
import type { ChartSpec } from "../viz/chartData";
|
| 135 |
import { MapView } from "./MapView";
|
|
|
|
| 136 |
/* βββ W18-C CATALOG βββ (owner item 4, contract C6) */
|
| 137 |
import { CatalogView } from "./CatalogView";
|
| 138 |
import { CATALOG_CODE_FIELD } from "./catalogData";
|
|
@@ -166,6 +167,7 @@ import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName,
|
|
| 166 |
isPickType, mayEditField,
|
| 167 |
isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
|
| 168 |
measureColumnIndex, ratingMax, ruleColumnKeys,
|
|
|
|
| 169 |
tableMode, uniqueDisplayName,
|
| 170 |
/* β W41-T38 β the rail's OWN name resolver, so a collision test compares what the reader
|
| 171 |
actually sees. The rail has always drawn `viewDisplayName(view)`; `renameView` was testing
|
|
@@ -264,6 +266,29 @@ const ONE_LINE = { whiteSpace: "nowrap" } as const;
|
|
| 264 |
function frozenCountOf(config: ViewConfig): number {
|
| 265 |
return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1));
|
| 266 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
/* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` β the id because a second
|
| 268 |
copy of a pinned literal is the drift class this repo gates against, the name because it is
|
| 269 |
now topic-derived and the host mints the same string. */
|
|
@@ -1357,7 +1382,14 @@ function CustomerGridSurface({
|
|
| 1357 |
const [bulkUpdateOpen, setBulkUpdateOpen] = useState(false);
|
| 1358 |
const [bulkFieldKey, setBulkFieldKey] = useState("");
|
| 1359 |
const [bulkValue, setBulkValue] = useState("");
|
|
|
|
| 1360 |
const bulkUpdateRef = useRef<HTMLButtonElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1361 |
/** Owner item 4 / C-ADDROW β the pid the trailing "+" just created, so the cursor can land on
|
| 1362 |
* it once the re-read actually brings it back (the row does not exist locally before that). */
|
| 1363 |
const [newRowPid, setNewRowPid] = useState<number | null>(null);
|
|
@@ -2972,17 +3004,69 @@ function CustomerGridSurface({
|
|
| 2972 |
embeddedSelectable ? embeddedSelectedIds : [],
|
| 2973 |
!embedded ? `aios:grid-selection:${scope}` : undefined
|
| 2974 |
);
|
| 2975 |
-
|
| 2976 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2977 |
useEffect(() => {
|
| 2978 |
-
if (!embeddedSelectable)
|
| 2979 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2980 |
selectPids(embeddedSelectedIds as number[], "replace");
|
| 2981 |
}, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
|
| 2982 |
useEffect(() => {
|
| 2983 |
if (!embeddedSelectable) return;
|
| 2984 |
onEmbeddedSelectionChange?.([...selectedPids]);
|
| 2985 |
}, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2986 |
/* ββββββββββββββββββββββββ owner item 16 / R4 / C-UNDO ββββββββββββββββββββββββ
|
| 2987 |
THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
|
| 2988 |
thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
|
|
@@ -3205,16 +3289,61 @@ function CustomerGridSurface({
|
|
| 3205 |
[currentValue, patchOverlay, recordCells]
|
| 3206 |
);
|
| 3207 |
const bulkEditableFields = useMemo(
|
| 3208 |
-
() => fields.filter((field) =>
|
| 3209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3210 |
);
|
| 3211 |
const bulkField = bulkEditableFields.find((field) => field.key === bulkFieldKey) ?? bulkEditableFields[0];
|
| 3212 |
-
const
|
| 3213 |
-
|
|
|
|
| 3214 |
const value = bulkField.type === "checkbox" ? (bulkValue === "1" ? "1" : "") : bulkValue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3215 |
patchManyAndRecord([...selectedPids].map((pid) => ({ pid, updates: { [bulkField.key]: value } })), "a bulk update");
|
| 3216 |
setBulkUpdateOpen(false);
|
| 3217 |
-
}, [bulkField, bulkValue,
|
|
|
|
| 3218 |
|
| 3219 |
/**
|
| 3220 |
* Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` β this
|
|
@@ -7066,6 +7195,13 @@ function CustomerGridSurface({
|
|
| 7066 |
}
|
| 7067 |
|
| 7068 |
const activeView = views.find((view) => view.id === activeViewId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7069 |
// Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover
|
| 7070 |
// it opens is with the other overlays at the bottom). Disabled without an active cohort β
|
| 7071 |
// zero cohorts is the host page's near-empty state, not this button's error to explain.
|
|
@@ -8671,6 +8807,8 @@ function CustomerGridSurface({
|
|
| 8671 |
}}
|
| 8672 |
onRouteFieldCreated={revealRouteField}
|
| 8673 |
routePanelOpen={routePanelOpen}
|
|
|
|
|
|
|
| 8674 |
/* β OWNER ITEM 9 β the route's "Add a record" search reaches PAST the view's own
|
| 8675 |
filter, so it is handed `scopedRows`: the book after the row wall (`allowed_pids`
|
| 8676 |
plus the business-unit scope) and before `useVisibleRows` applies filters, search
|
|
@@ -8799,6 +8937,42 @@ function CustomerGridSurface({
|
|
| 8799 |
broke this gate's own coordinate math before it broke a user). It states the
|
| 8800 |
count and offers "Add to cohort" over exactly the checked pids β the same guarded
|
| 8801 |
add_to_list event the view menu uses, so the host treats both alike. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8802 |
{!embedded && selectedPids.size > 0 && !serverWindowed &&
|
| 8803 |
(displayMode === "grid" || displayMode === "map") && (
|
| 8804 |
<div className="cg-selbar">
|
|
@@ -9456,7 +9630,7 @@ function CustomerGridSurface({
|
|
| 9456 |
{/* β Owner item 9 β the remove picker. Every row states how many of the CHECKED rows it
|
| 9457 |
would take out ("3 of 4"), because the selection and the cohort are two different sets
|
| 9458 |
and the difference is the whole reason this needs a picker rather than a button. */}
|
| 9459 |
-
{bulkUpdateOpen && bulkUpdateRef.current &&
|
| 9460 |
<AnchoredOverlay
|
| 9461 |
anchor={bulkUpdateRef.current}
|
| 9462 |
className="cg-pop cg-bulk-update-pop"
|
|
@@ -9467,7 +9641,7 @@ function CustomerGridSurface({
|
|
| 9467 |
dataKind="filtered-bulk-update"
|
| 9468 |
>
|
| 9469 |
<div className="cg-pop-title">Update field</div>
|
| 9470 |
-
<div className="cg-pop-note">Updates all {
|
| 9471 |
<select className="cg-select" aria-label="Field to update" value={bulkFieldKey || bulkField.key} onChange={(event) => { setBulkFieldKey(event.target.value); setBulkValue(""); }}>
|
| 9472 |
{bulkEditableFields.map((field) => <option key={field.key} value={field.key}>{field.label}</option>)}
|
| 9473 |
</select>
|
|
@@ -9479,7 +9653,10 @@ function CustomerGridSurface({
|
|
| 9479 |
<input className="cg-input" aria-label="New value" value={bulkValue} onChange={(event) => setBulkValue(event.target.value)} />
|
| 9480 |
)}
|
| 9481 |
<div className="cg-form-actions">
|
| 9482 |
-
<button type="button" className="cg-btn cg-btn--primary" style={ONE_LINE}
|
|
|
|
|
|
|
|
|
|
| 9483 |
<button type="button" className="cg-btn" style={ONE_LINE} onClick={() => setBulkUpdateOpen(false)}>Cancel</button>
|
| 9484 |
</div>
|
| 9485 |
</AnchoredOverlay>
|
|
@@ -9640,7 +9817,7 @@ function CustomerGridSurface({
|
|
| 9640 |
value={dateValue}
|
| 9641 |
today={today}
|
| 9642 |
onChange={(value) => patchAndRecord(datePicker.pid, { [dateField.key]: value }, "a date")}
|
| 9643 |
-
onRecurrence={(recurrence
|
| 9644 |
onClose={() => {
|
| 9645 |
setDatePicker(null);
|
| 9646 |
requestAnimationFrame(() => gridRef.current?.focus());
|
|
|
|
| 94 |
from "../apiContract";
|
| 95 |
import type { ViewOpenDetail } from "../apiContract";
|
| 96 |
import { addTableField, addTableRow, clearTopicRowsCache, deleteRouteOrderField, deleteTableField,
|
| 97 |
+
deleteTableRow, dropTableRowsCache, fetchLinkTargets, refusalMessage,
|
| 98 |
fetchRollupSources, patchRouteOrderField, patchTableField, enrichField } from "./apiBridge";
|
| 99 |
import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| 100 |
import SelectFromFile from "./SelectFromFile";
|
|
|
|
| 133 |
import { cleanCharts } from "../viz/chartData";
|
| 134 |
import type { ChartSpec } from "../viz/chartData";
|
| 135 |
import { MapView } from "./MapView";
|
| 136 |
+
import type { RouteSourceView } from "./MapView";
|
| 137 |
/* βββ W18-C CATALOG βββ (owner item 4, contract C6) */
|
| 138 |
import { CatalogView } from "./CatalogView";
|
| 139 |
import { CATALOG_CODE_FIELD } from "./catalogData";
|
|
|
|
| 167 |
isPickType, mayEditField,
|
| 168 |
isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock,
|
| 169 |
measureColumnIndex, ratingMax, ruleColumnKeys,
|
| 170 |
+
shouldAdoptEmbeddedSelection,
|
| 171 |
tableMode, uniqueDisplayName,
|
| 172 |
/* β W41-T38 β the rail's OWN name resolver, so a collision test compares what the reader
|
| 173 |
actually sees. The rail has always drawn `viewDisplayName(view)`; `renameView` was testing
|
|
|
|
| 266 |
function frozenCountOf(config: ViewConfig): number {
|
| 267 |
return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1));
|
| 268 |
}
|
| 269 |
+
|
| 270 |
+
/**
|
| 271 |
+
* A View has a durable id but no server-side revision counter on this wire. A key-sorted,
|
| 272 |
+
* deterministic configuration fingerprint is therefore the actual revision of the View state
|
| 273 |
+
* that produced a frozen route snapshot. It is provenance, not a permission token.
|
| 274 |
+
*/
|
| 275 |
+
function routeViewRevision(viewId: string, config: ViewConfig): string {
|
| 276 |
+
const canonical = (value: unknown): unknown => {
|
| 277 |
+
if (Array.isArray(value)) return value.map(canonical);
|
| 278 |
+
if (value && typeof value === "object") {
|
| 279 |
+
const out: Record<string, unknown> = {};
|
| 280 |
+
for (const key of Object.keys(value as Record<string, unknown>).sort())
|
| 281 |
+
out[key] = canonical((value as Record<string, unknown>)[key]);
|
| 282 |
+
return out;
|
| 283 |
+
}
|
| 284 |
+
return value;
|
| 285 |
+
};
|
| 286 |
+
const text = JSON.stringify(canonical({ viewId, config }));
|
| 287 |
+
let hash = 0x811c9dc5;
|
| 288 |
+
for (let index = 0; index < text.length; index += 1)
|
| 289 |
+
hash = Math.imul(hash ^ text.charCodeAt(index), 0x01000193);
|
| 290 |
+
return `view-config-v1-${(hash >>> 0).toString(36)}`;
|
| 291 |
+
}
|
| 292 |
/* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` β the id because a second
|
| 293 |
copy of a pinned literal is the drift class this repo gates against, the name because it is
|
| 294 |
now topic-derived and the host mints the same string. */
|
|
|
|
| 1382 |
const [bulkUpdateOpen, setBulkUpdateOpen] = useState(false);
|
| 1383 |
const [bulkFieldKey, setBulkFieldKey] = useState("");
|
| 1384 |
const [bulkValue, setBulkValue] = useState("");
|
| 1385 |
+
const [serverBulkPending, setServerBulkPending] = useState(false);
|
| 1386 |
const bulkUpdateRef = useRef<HTMLButtonElement>(null);
|
| 1387 |
+
/** A windowed "all filtered" selection lives on the server: a signed descriptor represents
|
| 1388 |
+
* every matching record, not the rows currently loaded in Glide. */
|
| 1389 |
+
const [serverFilteredSelection, setServerFilteredSelection] = useState<{
|
| 1390 |
+
token: string;
|
| 1391 |
+
matched: number;
|
| 1392 |
+
} | null>(null);
|
| 1393 |
/** Owner item 4 / C-ADDROW β the pid the trailing "+" just created, so the cursor can land on
|
| 1394 |
* it once the re-read actually brings it back (the row does not exist locally before that). */
|
| 1395 |
const [newRowPid, setNewRowPid] = useState<number | null>(null);
|
|
|
|
| 3004 |
embeddedSelectable ? embeddedSelectedIds : [],
|
| 3005 |
!embedded ? `aios:grid-selection:${scope}` : undefined
|
| 3006 |
);
|
| 3007 |
+
// A Link picker is controlled by its modal parent. Do not treat the parent's PREVIOUS prop as
|
| 3008 |
+
// a new command after the child has just ticked a row: doing that reset the checkbox before the
|
| 3009 |
+
// child effect could report the new selection upward, so it visibly flickered. Only a prop
|
| 3010 |
+
// value that actually changed since the last render is an external selection to apply.
|
| 3011 |
+
const embeddedSelectedKey = [...embeddedSelectedIds].sort((a, b) => a - b).join(",");
|
| 3012 |
+
const selectedPidKey = [...selectedPids].sort((a, b) => a - b).join(",");
|
| 3013 |
+
const embeddedPropKeyRef = useRef(embeddedSelectedKey);
|
| 3014 |
useEffect(() => {
|
| 3015 |
+
if (!embeddedSelectable) {
|
| 3016 |
+
embeddedPropKeyRef.current = embeddedSelectedKey;
|
| 3017 |
+
return;
|
| 3018 |
+
}
|
| 3019 |
+
const priorParentKey = embeddedPropKeyRef.current;
|
| 3020 |
+
if (embeddedSelectedKey === priorParentKey) return;
|
| 3021 |
+
embeddedPropKeyRef.current = embeddedSelectedKey;
|
| 3022 |
+
if (shouldAdoptEmbeddedSelection(priorParentKey, embeddedSelectedKey, selectedPidKey))
|
| 3023 |
selectPids(embeddedSelectedIds as number[], "replace");
|
| 3024 |
}, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
|
| 3025 |
useEffect(() => {
|
| 3026 |
if (!embeddedSelectable) return;
|
| 3027 |
onEmbeddedSelectionChange?.([...selectedPids]);
|
| 3028 |
}, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]);
|
| 3029 |
+
// A descriptor binds the exact filter/search/sort at the time the person chose "Select all".
|
| 3030 |
+
// Do not let it survive a predicate change and silently mean a different population.
|
| 3031 |
+
useEffect(() => {
|
| 3032 |
+
setServerFilteredSelection(null);
|
| 3033 |
+
}, [serverWindowed, windowPredicate]);
|
| 3034 |
+
const selectAllServerFiltered = useCallback(async () => {
|
| 3035 |
+
if (!serverWindowed) return;
|
| 3036 |
+
try {
|
| 3037 |
+
const response = await fetch(
|
| 3038 |
+
`${API_V1}/odoo-tables/${encodeURIComponent(scope)}/filtered-selection`,
|
| 3039 |
+
{
|
| 3040 |
+
method: "POST",
|
| 3041 |
+
credentials: CREDENTIALS,
|
| 3042 |
+
headers: { "Content-Type": "application/json" },
|
| 3043 |
+
body: JSON.stringify({
|
| 3044 |
+
filters: config.filters,
|
| 3045 |
+
filterConj: config.filterConj,
|
| 3046 |
+
sorts: config.sorts,
|
| 3047 |
+
search,
|
| 3048 |
+
}),
|
| 3049 |
+
}
|
| 3050 |
+
);
|
| 3051 |
+
const payload = await response.json().catch(() => null) as {
|
| 3052 |
+
selection?: unknown; matched?: unknown; error?: { message?: unknown };
|
| 3053 |
+
} | null;
|
| 3054 |
+
if (!response.ok) {
|
| 3055 |
+
signal(TOAST_EVENT, refusalMessage(payload, response.status));
|
| 3056 |
+
return;
|
| 3057 |
+
}
|
| 3058 |
+
const token = typeof payload?.selection === "string" ? payload.selection : "";
|
| 3059 |
+
const matched = typeof payload?.matched === "number" ? payload.matched : 0;
|
| 3060 |
+
if (!token || matched <= 0) {
|
| 3061 |
+
setServerFilteredSelection(null);
|
| 3062 |
+
signal(TOAST_EVENT, "No records match this filtered view.");
|
| 3063 |
+
return;
|
| 3064 |
+
}
|
| 3065 |
+
setServerFilteredSelection({ token, matched });
|
| 3066 |
+
} catch {
|
| 3067 |
+
signal(TOAST_EVENT, "Could not select all filtered records. Try again.");
|
| 3068 |
+
}
|
| 3069 |
+
}, [serverWindowed, scope, config.filters, config.filterConj, config.sorts, search]);
|
| 3070 |
/* ββββββββββββββββββββββββ owner item 16 / R4 / C-UNDO ββββββββββββββββββββββββ
|
| 3071 |
THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
|
| 3072 |
thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
|
|
|
|
| 3289 |
[currentValue, patchOverlay, recordCells]
|
| 3290 |
);
|
| 3291 |
const bulkEditableFields = useMemo(
|
| 3292 |
+
() => fields.filter((field) => (
|
| 3293 |
+
serverWindowed
|
| 3294 |
+
// Connected records themselves are source-owned. Only an already-declared shared
|
| 3295 |
+
// overlay field is writable through the server descriptor, with the usual field grant.
|
| 3296 |
+
? field.shared === true && mayEditField(field, viewer)
|
| 3297 |
+
: canEditField(field)
|
| 3298 |
+
) && field.type !== "rating"),
|
| 3299 |
+
[fields, serverWindowed, viewer, canEditField]
|
| 3300 |
);
|
| 3301 |
const bulkField = bulkEditableFields.find((field) => field.key === bulkFieldKey) ?? bulkEditableFields[0];
|
| 3302 |
+
const bulkSelectedCount = serverFilteredSelection?.matched ?? selectedPids.size;
|
| 3303 |
+
const applyBulkUpdate = useCallback(async () => {
|
| 3304 |
+
if (!bulkField || bulkSelectedCount === 0 || serverBulkPending) return;
|
| 3305 |
const value = bulkField.type === "checkbox" ? (bulkValue === "1" ? "1" : "") : bulkValue;
|
| 3306 |
+
if (serverFilteredSelection) {
|
| 3307 |
+
setServerBulkPending(true);
|
| 3308 |
+
try {
|
| 3309 |
+
const response = await fetch(
|
| 3310 |
+
`${API_V1}/odoo-tables/${encodeURIComponent(scope)}/filtered-bulk`,
|
| 3311 |
+
{
|
| 3312 |
+
method: "POST",
|
| 3313 |
+
credentials: CREDENTIALS,
|
| 3314 |
+
headers: { "Content-Type": "application/json" },
|
| 3315 |
+
body: JSON.stringify({ selection: serverFilteredSelection.token, field: bulkField.key, value }),
|
| 3316 |
+
}
|
| 3317 |
+
);
|
| 3318 |
+
const payload = await response.json().catch(() => null) as {
|
| 3319 |
+
rows_written?: unknown; matched?: unknown; error?: { message?: unknown };
|
| 3320 |
+
} | null;
|
| 3321 |
+
if (!response.ok) {
|
| 3322 |
+
signal(TOAST_EVENT, refusalMessage(payload, response.status));
|
| 3323 |
+
return;
|
| 3324 |
+
}
|
| 3325 |
+
const written = typeof payload?.rows_written === "number" ? payload.rows_written : 0;
|
| 3326 |
+
const matched = typeof payload?.matched === "number" ? payload.matched : bulkSelectedCount;
|
| 3327 |
+
// Do not paint loaded rows as though they were the whole update. Invalidate every cached
|
| 3328 |
+
// window and request a fresh one, then state exactly what the server says it wrote.
|
| 3329 |
+
dropTableRowsCache(scope);
|
| 3330 |
+
requestWindow(windowRequest(0));
|
| 3331 |
+
signal(ROWS_STALE_EVENT);
|
| 3332 |
+
setServerFilteredSelection(null);
|
| 3333 |
+
clearSelection();
|
| 3334 |
+
setBulkUpdateOpen(false);
|
| 3335 |
+
signal(TOAST_EVENT, `Updated ${written.toLocaleString()} of ${matched.toLocaleString()} filtered records.`);
|
| 3336 |
+
} catch {
|
| 3337 |
+
signal(TOAST_EVENT, "Could not update the filtered records. Try again.");
|
| 3338 |
+
} finally {
|
| 3339 |
+
setServerBulkPending(false);
|
| 3340 |
+
}
|
| 3341 |
+
return;
|
| 3342 |
+
}
|
| 3343 |
patchManyAndRecord([...selectedPids].map((pid) => ({ pid, updates: { [bulkField.key]: value } })), "a bulk update");
|
| 3344 |
setBulkUpdateOpen(false);
|
| 3345 |
+
}, [bulkField, bulkSelectedCount, bulkValue, clearSelection, dropTableRowsCache, patchManyAndRecord,
|
| 3346 |
+
requestWindow, scope, selectedPids, serverBulkPending, serverFilteredSelection, windowRequest]);
|
| 3347 |
|
| 3348 |
/**
|
| 3349 |
* Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` β this
|
|
|
|
| 7195 |
}
|
| 7196 |
|
| 7197 |
const activeView = views.find((view) => view.id === activeViewId);
|
| 7198 |
+
const routeSourceView: RouteSourceView | null = activeView
|
| 7199 |
+
? {
|
| 7200 |
+
id: activeView.id,
|
| 7201 |
+
name: viewDisplayName(activeView),
|
| 7202 |
+
revision: routeViewRevision(activeView.id, config),
|
| 7203 |
+
}
|
| 7204 |
+
: null;
|
| 7205 |
// Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover
|
| 7206 |
// it opens is with the other overlays at the bottom). Disabled without an active cohort β
|
| 7207 |
// zero cohorts is the host page's near-empty state, not this button's error to explain.
|
|
|
|
| 8807 |
}}
|
| 8808 |
onRouteFieldCreated={revealRouteField}
|
| 8809 |
routePanelOpen={routePanelOpen}
|
| 8810 |
+
activeView={routeSourceView}
|
| 8811 |
+
tableKey={topic.key}
|
| 8812 |
/* β OWNER ITEM 9 β the route's "Add a record" search reaches PAST the view's own
|
| 8813 |
filter, so it is handed `scopedRows`: the book after the row wall (`allowed_pids`
|
| 8814 |
plus the business-unit scope) and before `useVisibleRows` applies filters, search
|
|
|
|
| 8937 |
broke this gate's own coordinate math before it broke a user). It states the
|
| 8938 |
count and offers "Add to cohort" over exactly the checked pids β the same guarded
|
| 8939 |
add_to_list event the view menu uses, so the host treats both alike. */}
|
| 8940 |
+
{!embedded && serverWindowed &&
|
| 8941 |
+
(selectedPids.size > 0 || serverFilteredSelection !== null) &&
|
| 8942 |
+
displayMode === "grid" && (
|
| 8943 |
+
<div className="cg-selbar" data-kind="server-filtered-selection">
|
| 8944 |
+
<span className="cg-selbar-count">
|
| 8945 |
+
{bulkSelectedCount.toLocaleString()} selected
|
| 8946 |
+
</span>
|
| 8947 |
+
{!serverFilteredSelection && (payload?.counts?.matched ?? 0) > 0 && (
|
| 8948 |
+
<button
|
| 8949 |
+
type="button"
|
| 8950 |
+
className="cg-btn cg-selbar-all"
|
| 8951 |
+
style={ONE_LINE}
|
| 8952 |
+
onClick={() => void selectAllServerFiltered()}
|
| 8953 |
+
>
|
| 8954 |
+
Select all {(payload?.counts?.matched ?? 0).toLocaleString()} filtered
|
| 8955 |
+
</button>
|
| 8956 |
+
)}
|
| 8957 |
+
{bulkEditableFields.length > 0 && (
|
| 8958 |
+
<button
|
| 8959 |
+
ref={bulkUpdateRef}
|
| 8960 |
+
type="button"
|
| 8961 |
+
className="cg-btn cg-selbar-update"
|
| 8962 |
+
style={ONE_LINE}
|
| 8963 |
+
aria-expanded={bulkUpdateOpen}
|
| 8964 |
+
aria-haspopup="dialog"
|
| 8965 |
+
onClick={() => setBulkUpdateOpen((open) => !open)}
|
| 8966 |
+
>
|
| 8967 |
+
Update field
|
| 8968 |
+
</button>
|
| 8969 |
+
)}
|
| 8970 |
+
<button type="button" className="cg-btn" style={ONE_LINE}
|
| 8971 |
+
onClick={() => { setServerFilteredSelection(null); clearSelection(); }}>
|
| 8972 |
+
Clear
|
| 8973 |
+
</button>
|
| 8974 |
+
</div>
|
| 8975 |
+
)}
|
| 8976 |
{!embedded && selectedPids.size > 0 && !serverWindowed &&
|
| 8977 |
(displayMode === "grid" || displayMode === "map") && (
|
| 8978 |
<div className="cg-selbar">
|
|
|
|
| 9630 |
{/* β Owner item 9 β the remove picker. Every row states how many of the CHECKED rows it
|
| 9631 |
would take out ("3 of 4"), because the selection and the cohort are two different sets
|
| 9632 |
and the difference is the whole reason this needs a picker rather than a button. */}
|
| 9633 |
+
{bulkUpdateOpen && bulkUpdateRef.current && bulkSelectedCount > 0 && bulkField && (
|
| 9634 |
<AnchoredOverlay
|
| 9635 |
anchor={bulkUpdateRef.current}
|
| 9636 |
className="cg-pop cg-bulk-update-pop"
|
|
|
|
| 9641 |
dataKind="filtered-bulk-update"
|
| 9642 |
>
|
| 9643 |
<div className="cg-pop-title">Update field</div>
|
| 9644 |
+
<div className="cg-pop-note">Updates all {bulkSelectedCount.toLocaleString()} selected records in this filtered view.</div>
|
| 9645 |
<select className="cg-select" aria-label="Field to update" value={bulkFieldKey || bulkField.key} onChange={(event) => { setBulkFieldKey(event.target.value); setBulkValue(""); }}>
|
| 9646 |
{bulkEditableFields.map((field) => <option key={field.key} value={field.key}>{field.label}</option>)}
|
| 9647 |
</select>
|
|
|
|
| 9653 |
<input className="cg-input" aria-label="New value" value={bulkValue} onChange={(event) => setBulkValue(event.target.value)} />
|
| 9654 |
)}
|
| 9655 |
<div className="cg-form-actions">
|
| 9656 |
+
<button type="button" className="cg-btn cg-btn--primary" style={ONE_LINE}
|
| 9657 |
+
disabled={serverBulkPending} onClick={() => void applyBulkUpdate()}>
|
| 9658 |
+
{serverBulkPending ? "Updating recordsβ¦" : "Update records"}
|
| 9659 |
+
</button>
|
| 9660 |
<button type="button" className="cg-btn" style={ONE_LINE} onClick={() => setBulkUpdateOpen(false)}>Cancel</button>
|
| 9661 |
</div>
|
| 9662 |
</AnchoredOverlay>
|
|
|
|
| 9817 |
value={dateValue}
|
| 9818 |
today={today}
|
| 9819 |
onChange={(value) => patchAndRecord(datePicker.pid, { [dateField.key]: value }, "a date")}
|
| 9820 |
+
onRecurrence={(recurrence) => saveField({ ...dateField, recurrence: recurrence ?? null })}
|
| 9821 |
onClose={() => {
|
| 9822 |
setDatePicker(null);
|
| 9823 |
requestAnimationFrame(() => gridRef.current?.focus());
|
web/src/customer-grid/DatePicker.tsx
CHANGED
|
@@ -1,165 +1,106 @@
|
|
| 1 |
-
import { useMemo, useState } from "react";
|
| 2 |
-
import
|
|
|
|
| 3 |
import { AnchoredOverlay } from "./OverlaySurface";
|
| 4 |
import type { AnchorRect } from "./OverlaySurface";
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
const MONTHS = [
|
| 9 |
-
"January", "February", "March", "April", "May", "June",
|
| 10 |
-
"July", "August", "September", "October", "November", "December",
|
| 11 |
-
];
|
| 12 |
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
function parseIso(value: unknown): Date | null {
|
| 15 |
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
| 16 |
const [year, month, day] = value.split("-").map(Number);
|
| 17 |
const date = new Date(Date.UTC(year, month - 1, day));
|
| 18 |
-
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
| 19 |
-
? date
|
| 20 |
-
: null;
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
function iso(date: Date): string {
|
| 24 |
-
return date.toISOString().slice(0, 10);
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
function addMonths(date: Date, amount: number): Date {
|
| 28 |
-
const out = new Date(date);
|
| 29 |
-
out.setUTCMonth(out.getUTCMonth() + amount);
|
| 30 |
-
return out;
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
function isUsHoliday(date: Date): boolean {
|
| 34 |
-
const month = date.getUTCMonth();
|
| 35 |
-
const day = date.getUTCDate();
|
| 36 |
-
// Fixed federal holidays plus the common observed weekday. The API remains the authority for
|
| 37 |
-
// an exact calendar; this prevents the local preview from presenting a known holiday as a work day.
|
| 38 |
-
if ((month === 0 && day === 1) || (month === 6 && day === 4) || (month === 11 && day === 25)) return true;
|
| 39 |
-
const weekday = date.getUTCDay();
|
| 40 |
-
if (month === 0 && day === 1 && weekday === 6) return true;
|
| 41 |
-
if (month === 0 && day === 2 && weekday === 1) return true;
|
| 42 |
-
if (month === 6 && day === 3 && weekday === 5) return true;
|
| 43 |
-
if (month === 6 && day === 5 && weekday === 1) return true;
|
| 44 |
-
if (month === 11 && day === 24 && weekday === 5) return true;
|
| 45 |
-
if (month === 11 && day === 26 && weekday === 1) return true;
|
| 46 |
-
// MLK, Memorial, Labor and Thanksgiving.
|
| 47 |
-
if (month === 0 && weekday === 1 && day >= 15 && day <= 21) return true;
|
| 48 |
-
if (month === 4 && weekday === 1 && day + 7 > 31) return true;
|
| 49 |
-
if (month === 8 && weekday === 1 && day <= 7) return true;
|
| 50 |
-
return month === 10 && weekday === 4 && day >= 22 && day <= 28;
|
| 51 |
}
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
):
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
if (
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
let candidate = new Date(seed);
|
| 64 |
-
let guard = 0;
|
| 65 |
-
while (out.length < count && guard++ < count * 400) {
|
| 66 |
-
if (guard > 1) {
|
| 67 |
-
if (frequency === "daily") candidate.setUTCDate(candidate.getUTCDate() + interval);
|
| 68 |
-
else if (frequency === "weekly") candidate.setUTCDate(candidate.getUTCDate() + 7 * interval);
|
| 69 |
-
else if (frequency === "monthly") candidate = addMonths(candidate, interval);
|
| 70 |
-
else candidate.setUTCFullYear(candidate.getUTCFullYear() + interval);
|
| 71 |
-
}
|
| 72 |
-
const weekday = candidate.getUTCDay();
|
| 73 |
-
const allowedWeekday = !recurrence?.weekdays?.length || recurrence.weekdays.includes(weekday);
|
| 74 |
-
const holiday = recurrence?.skipHolidays && recurrence.holidayCalendar === "us" && isUsHoliday(candidate);
|
| 75 |
-
if (allowedWeekday && !holiday) out.push(iso(candidate));
|
| 76 |
-
}
|
| 77 |
-
return out;
|
| 78 |
}
|
|
|
|
| 79 |
|
| 80 |
-
export function DatePicker({
|
| 81 |
-
anchor
|
| 82 |
-
|
| 83 |
-
value,
|
| 84 |
-
today,
|
| 85 |
-
onChange,
|
| 86 |
-
onRecurrence,
|
| 87 |
-
onClose,
|
| 88 |
-
}: {
|
| 89 |
-
anchor: AnchorRect;
|
| 90 |
-
field: Field;
|
| 91 |
-
value: unknown;
|
| 92 |
-
today?: string;
|
| 93 |
-
onChange: (value: string) => void;
|
| 94 |
-
onRecurrence: (recurrence: NonNullable<Field["recurrence"]>, nextDate: string | undefined) => void;
|
| 95 |
-
onClose: () => void;
|
| 96 |
}) {
|
| 97 |
const selected = parseIso(value) ?? parseIso(today) ?? new Date();
|
|
|
|
| 98 |
const [cursor, setCursor] = useState(() => new Date(Date.UTC(selected.getUTCFullYear(), selected.getUTCMonth(), 1)));
|
| 99 |
-
const [
|
| 100 |
-
const [
|
| 101 |
-
const [
|
| 102 |
-
const
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const monthStart = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), 1));
|
| 117 |
const leading = (monthStart.getUTCDay() + 6) % 7;
|
| 118 |
const days = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0)).getUTCDate();
|
| 119 |
const cells = Array.from({ length: Math.ceil((leading + days) / 7) * 7 }, (_, index) => index - leading + 1);
|
| 120 |
-
const selectedIso = iso(selected);
|
| 121 |
|
| 122 |
-
return
|
| 123 |
-
<
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
<
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
{
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
<div className="cg-date-recurrence">
|
| 147 |
-
<div className="cg-pop-title">Repeat</div>
|
| 148 |
-
<label>Every <input className="cg-input cg-date-interval" min="1" max="365" type="number" value={interval} onChange={(event) => setInterval(Number(event.target.value))} /></label>
|
| 149 |
-
<select className="cg-select" value={frequency} aria-label="Recurrence frequency" onChange={(event) => setFrequency(event.target.value as Frequency)}>
|
| 150 |
-
<option value="none">Does not repeat</option>
|
| 151 |
-
<option value="daily">day</option>
|
| 152 |
-
<option value="weekly">week</option>
|
| 153 |
-
<option value="monthly">month</option>
|
| 154 |
-
<option value="yearly">year</option>
|
| 155 |
-
</select>
|
| 156 |
-
{frequency !== "none" && <label className="cg-date-holidays"><input type="checkbox" checked={skipHolidays} onChange={(event) => setSkipHolidays(event.target.checked)} /> Skip US public holidays</label>}
|
| 157 |
-
{frequency !== "none" && <button type="button" className="cg-link-btn" onClick={() => onRecurrence(recurrence, next[0])}>Save recurrence</button>}
|
| 158 |
-
</div>
|
| 159 |
-
<div className="cg-date-preview" aria-live="polite">
|
| 160 |
-
<div className="cg-pop-title">Next dates</div>
|
| 161 |
-
{next.slice(0, 6).map((date) => <span key={date}>{date}</span>)}
|
| 162 |
-
</div>
|
| 163 |
-
</AnchoredOverlay>
|
| 164 |
-
);
|
| 165 |
}
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useState } from "react";
|
| 2 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 3 |
+
import type { DateRecurrence, DateRecurrenceRule, Field } from "./types";
|
| 4 |
import { AnchoredOverlay } from "./OverlaySurface";
|
| 5 |
import type { AnchorRect } from "./OverlaySurface";
|
| 6 |
|
| 7 |
+
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
| 9 |
+
const ORDINALS: ReadonlyArray<readonly [DateRecurrenceRule["ordinal"], string]> = [[1, "First"], [2, "Second"], [3, "Third"], [4, "Fourth"], [5, "Fifth"], [-1, "Last"]];
|
| 10 |
+
const DEFAULT_CALENDARS = [{ code: "US" as const, label: "United States federal holidays" }, { code: "CA" as const, label: "Canada federal public holidays" }];
|
| 11 |
+
type RuleKind = DateRecurrenceRule["kind"] | "monthly_ordinal";
|
| 12 |
+
type CalendarOption = { code: "US" | "CA"; label: string };
|
| 13 |
|
| 14 |
function parseIso(value: unknown): Date | null {
|
| 15 |
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
| 16 |
const [year, month, day] = value.split("-").map(Number);
|
| 17 |
const date = new Date(Date.UTC(year, month - 1, day));
|
| 18 |
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
+
function iso(date: Date): string { return date.toISOString().slice(0, 10); }
|
| 21 |
+
function addMonths(date: Date, amount: number): Date { const out = new Date(date); out.setUTCMonth(out.getUTCMonth() + amount); return out; }
|
| 22 |
+
function bounded(value: unknown, lower: number, upper: number, fallback: number): number { const number = Math.round(Number(value)); return Number.isFinite(number) ? Math.max(lower, Math.min(upper, number)) : fallback; }
|
| 23 |
+
function ruleKind(rule: DateRecurrenceRule): RuleKind { return rule.kind === "monthly" && rule.ordinal !== undefined ? "monthly_ordinal" : rule.kind; }
|
| 24 |
+
function newRule(kind: RuleKind, selected: Date): DateRecurrenceRule {
|
| 25 |
+
if (kind === "weekly") return { kind, interval: 1, weekdays: [((selected.getUTCDay() + 6) % 7)] };
|
| 26 |
+
if (kind === "monthly_ordinal") return { kind: "monthly", interval: 1, ordinal: 1, weekday: 0 };
|
| 27 |
+
if (kind === "monthly") return { kind, interval: 1, day: selected.getUTCDate() };
|
| 28 |
+
if (kind === "yearly") return { kind, interval: 1, month: selected.getUTCMonth() + 1, day: selected.getUTCDate() };
|
| 29 |
+
if (kind === "holiday") return { kind, interval: 1, calendar: "US", offset: 0 };
|
| 30 |
+
return { kind, interval: 1 };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
+
function labelForKind(kind: RuleKind): string { return ({ daily: "Every N days", weekly: "Every N weeks", monthly: "Day of every N months", monthly_ordinal: "Ordinal weekday of every N months", yearly: "Every N years", holiday: "Public holiday" } as Record<RuleKind, string>)[kind]; }
|
| 33 |
|
| 34 |
+
export function DatePicker({ anchor, field, value, today, onChange, onRecurrence, onClose }: {
|
| 35 |
+
anchor: AnchorRect; field: Field; value: unknown; today?: string; onChange: (value: string) => void;
|
| 36 |
+
onRecurrence: (recurrence: DateRecurrence | null) => void; onClose: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}) {
|
| 38 |
const selected = parseIso(value) ?? parseIso(today) ?? new Date();
|
| 39 |
+
const selectedIso = iso(selected);
|
| 40 |
const [cursor, setCursor] = useState(() => new Date(Date.UTC(selected.getUTCFullYear(), selected.getUTCMonth(), 1)));
|
| 41 |
+
const [rules, setRules] = useState<DateRecurrenceRule[]>(() => field.recurrence?.rules ?? []);
|
| 42 |
+
const [previewCount, setPreviewCount] = useState(() => field.recurrence?.previewCount ?? 10);
|
| 43 |
+
const [preview, setPreview] = useState<string[]>(() => field.recurrence?.preview ?? []);
|
| 44 |
+
const [nextDate, setNextDate] = useState<string | null | undefined>(() => field.recurrence?.nextDate);
|
| 45 |
+
const [calendars, setCalendars] = useState<CalendarOption[]>(DEFAULT_CALENDARS);
|
| 46 |
+
const [previewError, setPreviewError] = useState("");
|
| 47 |
+
const recurrence = useMemo<DateRecurrence | null>(() => rules.length ? ({ startDate: selectedIso, rules, previewCount: bounded(previewCount, 1, 50, 10) }) : null, [previewCount, rules, selectedIso]);
|
| 48 |
+
|
| 49 |
+
// Server-evaluated preview: the exact payload saved below is sent here, so edited previews
|
| 50 |
+
// cannot differ from the default next date after refresh because of timezone/holiday drift.
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
if (!recurrence) { setPreview([]); setNextDate(undefined); setPreviewError(""); return; }
|
| 53 |
+
const controller = new AbortController();
|
| 54 |
+
void fetch(`${API_V1}/grid/recurrence-preview`, { method: "POST", credentials: CREDENTIALS, signal: controller.signal,
|
| 55 |
+
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ recurrence })
|
| 56 |
+
}).then(async (response) => {
|
| 57 |
+
const data = await response.json().catch(() => ({}));
|
| 58 |
+
if (!response.ok) throw new Error(String(data?.detail?.message ?? "Could not preview recurrence"));
|
| 59 |
+
return data as { recurrence?: DateRecurrence; calendars?: CalendarOption[] };
|
| 60 |
+
}).then((data) => {
|
| 61 |
+
if (controller.signal.aborted) return;
|
| 62 |
+
if (data.recurrence) { setPreview(data.recurrence.preview ?? []); setNextDate(data.recurrence.nextDate); }
|
| 63 |
+
if (Array.isArray(data.calendars) && data.calendars.length) setCalendars(data.calendars);
|
| 64 |
+
setPreviewError("");
|
| 65 |
+
}).catch((error: unknown) => { if (!controller.signal.aborted) setPreviewError(error instanceof Error ? error.message : "Could not preview recurrence"); });
|
| 66 |
+
return () => controller.abort();
|
| 67 |
+
}, [recurrence]);
|
| 68 |
+
|
| 69 |
+
const updateRule = (index: number, patch: Partial<DateRecurrenceRule>) => setRules((current) => current.map((rule, item) => item === index ? { ...rule, ...patch } : rule));
|
| 70 |
+
const replaceKind = (index: number, kind: RuleKind) => setRules((current) => current.map((rule, item) => item === index ? newRule(kind, selected) : rule));
|
| 71 |
+
const toggleWeekday = (index: number, weekday: number, checked: boolean) => setRules((current) => current.map((rule, item) => {
|
| 72 |
+
if (item !== index) return rule;
|
| 73 |
+
const existing = rule.weekdays ?? [];
|
| 74 |
+
const weekdays = checked ? [...new Set([...existing, weekday])].sort() : existing.filter((day) => day !== weekday);
|
| 75 |
+
return { ...rule, weekdays: weekdays.length ? weekdays : existing };
|
| 76 |
+
}));
|
| 77 |
const monthStart = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), 1));
|
| 78 |
const leading = (monthStart.getUTCDay() + 6) % 7;
|
| 79 |
const days = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0)).getUTCDate();
|
| 80 |
const cells = Array.from({ length: Math.ceil((leading + days) / 7) * 7 }, (_, index) => index - leading + 1);
|
|
|
|
| 81 |
|
| 82 |
+
return <AnchoredOverlay anchor={anchor} className="cg-date-picker" placement="bottom-start" onDismiss={onClose} role="dialog" ariaLabel={`Choose a date for ${field.label}`} dataKind="date-picker">
|
| 83 |
+
<div className="cg-date-picker-head"><button type="button" className="cg-icon-button" aria-label="Previous month" onClick={() => setCursor(addMonths(cursor, -1))}>βΉ</button><strong>{MONTHS[cursor.getUTCMonth()]} {cursor.getUTCFullYear()}</strong><button type="button" className="cg-icon-button" aria-label="Next month" onClick={() => setCursor(addMonths(cursor, 1))}>βΊ</button></div>
|
| 84 |
+
<div className="cg-date-weekdays">{WEEKDAYS.map((day) => <span key={day}>{day}</span>)}</div>
|
| 85 |
+
<div className="cg-date-grid">{cells.map((day, index) => {
|
| 86 |
+
if (day < 1 || day > days) return <span key={`blank-${index}`} aria-hidden />;
|
| 87 |
+
const dateIso = iso(new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), day)));
|
| 88 |
+
return <button key={dateIso} type="button" className={"cg-date-day" + (dateIso === selectedIso ? " is-selected" : "")} aria-pressed={dateIso === selectedIso} onClick={() => onChange(dateIso)}>{day}</button>;
|
| 89 |
+
})}</div>
|
| 90 |
+
<div className="cg-date-recurrence"><div className="cg-pop-title">Repeat rules</div>
|
| 91 |
+
{rules.map((rule, index) => <div className="cg-date-rule" key={`${rule.kind}-${index}`}>
|
| 92 |
+
<select className="cg-select" value={ruleKind(rule)} aria-label={`Repeat rule ${index + 1}`} onChange={(event) => replaceKind(index, event.target.value as RuleKind)}>{(["daily", "weekly", "monthly", "monthly_ordinal", "yearly", "holiday"] as RuleKind[]).map((kind) => <option key={kind} value={kind}>{labelForKind(kind)}</option>)}</select>
|
| 93 |
+
<label>Every <input className="cg-input cg-date-interval" min="1" max="366" type="number" value={rule.interval ?? 1} onChange={(event) => updateRule(index, { interval: bounded(event.target.value, 1, 366, 1) })} /></label>
|
| 94 |
+
{rule.kind === "weekly" && <div className="cg-date-holidays" aria-label="Weekdays">{WEEKDAYS.map((day, weekday) => <label key={day}><input type="checkbox" checked={(rule.weekdays ?? []).includes(weekday)} onChange={(event) => toggleWeekday(index, weekday, event.target.checked)} />{day}</label>)}</div>}
|
| 95 |
+
{rule.kind === "monthly" && rule.ordinal === undefined && <label>Day <input className="cg-input cg-date-interval" min="1" max="31" type="number" value={rule.day ?? 1} onChange={(event) => updateRule(index, { day: bounded(event.target.value, 1, 31, 1) })} /></label>}
|
| 96 |
+
{rule.kind === "monthly" && rule.ordinal !== undefined && <div className="cg-date-holidays"><select className="cg-select" value={rule.ordinal} aria-label="Month ordinal" onChange={(event) => updateRule(index, { ordinal: Number(event.target.value) as DateRecurrenceRule["ordinal"] })}>{ORDINALS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select><select className="cg-select" value={rule.weekday ?? 0} aria-label="Month weekday" onChange={(event) => updateRule(index, { weekday: Number(event.target.value) })}>{WEEKDAYS.map((day, weekday) => <option key={day} value={weekday}>{day}</option>)}</select></div>}
|
| 97 |
+
{rule.kind === "yearly" && <div className="cg-date-holidays"><label>Month <input className="cg-input cg-date-interval" min="1" max="12" type="number" value={rule.month ?? 1} onChange={(event) => updateRule(index, { month: bounded(event.target.value, 1, 12, 1) })} /></label><label>Day <input className="cg-input cg-date-interval" min="1" max="31" type="number" value={rule.day ?? 1} onChange={(event) => updateRule(index, { day: bounded(event.target.value, 1, 31, 1) })} /></label></div>}
|
| 98 |
+
{rule.kind === "holiday" && <div className="cg-date-holidays"><select className="cg-select" value={rule.calendar ?? "US"} aria-label="Public calendar" onChange={(event) => updateRule(index, { calendar: event.target.value as "US" | "CA" })}>{calendars.map((calendar) => <option key={calendar.code} value={calendar.code}>{calendar.label}</option>)}</select><select className="cg-select" value={rule.offset ?? 0} aria-label="Holiday relation" onChange={(event) => updateRule(index, { offset: Number(event.target.value) as -1 | 0 | 1 })}><option value={-1}>Day before</option><option value={0}>Day on</option><option value={1}>Day after</option></select></div>}
|
| 99 |
+
<button type="button" className="cg-link-btn" onClick={() => setRules((current) => current.filter((_, item) => item !== index))}>Remove rule</button>
|
| 100 |
+
</div>)}
|
| 101 |
+
<button type="button" className="cg-link-btn" onClick={() => setRules((current) => [...current, newRule("daily", selected)])}>+ Add repeat rule</button>
|
| 102 |
+
{rules.length > 0 && <><label>Show next <select className="cg-select" value={previewCount} aria-label="Number of preview dates" onChange={(event) => setPreviewCount(bounded(event.target.value, 1, 50, 10))}>{[3, 5, 10, 20, 50].map((count) => <option key={count} value={count}>{count}</option>)}</select> dates</label><div className="cg-date-holidays"><button type="button" className="cg-link-btn" onClick={() => recurrence && onRecurrence(recurrence)}>Save recurrence</button><button type="button" className="cg-link-btn" onClick={() => onRecurrence(null)}>Clear recurrence</button></div></>}
|
| 103 |
+
</div>
|
| 104 |
+
<div className="cg-date-preview" aria-live="polite"><div className="cg-pop-title">Next dates{nextDate ? ` Β· next ${nextDate}` : ""}</div>{preview.map((date) => <span key={date}>{date}</span>)}{rules.length > 0 && !preview.length && !previewError && <span>Calculating the server previewβ¦</span>}{previewError && <span role="status">{previewError}</span>}</div>
|
| 105 |
+
</AnchoredOverlay>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
web/src/customer-grid/MapView.tsx
CHANGED
|
@@ -326,6 +326,22 @@ type ZoneGeometry = {
|
|
| 326 |
coordinates: number[][][] | number[][][][];
|
| 327 |
};
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
/** C3's durable map-domain DTO. Zones are not generic fields: geometry, visibility and the
|
| 330 |
* membership snapshot only make sense to this projection. */
|
| 331 |
interface MapZone {
|
|
@@ -340,6 +356,11 @@ interface MapZone {
|
|
| 340 |
createdAt?: string;
|
| 341 |
}
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
interface RouteDelivery {
|
| 344 |
id: string;
|
| 345 |
name: string;
|
|
@@ -356,6 +377,13 @@ interface RouteDelivery {
|
|
| 356 |
};
|
| 357 |
}
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
interface MapPoint {
|
| 360 |
pid: number;
|
| 361 |
title: string;
|
|
@@ -400,6 +428,8 @@ export function MapView({
|
|
| 400 |
onRouteFieldCreated,
|
| 401 |
routePanelOpen,
|
| 402 |
allRows,
|
|
|
|
|
|
|
| 403 |
}: {
|
| 404 |
/** DISTINCT data rows from the full pipeline, overlay edits layered β the
|
| 405 |
* calendar/kanban contract, verbatim. */
|
|
@@ -437,6 +467,10 @@ export function MapView({
|
|
| 437 |
onGeocoded?: (writes: { pid: number; value: string }[]) => void;
|
| 438 |
onRouteFieldCreated?: (key: string) => void;
|
| 439 |
routePanelOpen: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
/**
|
| 441 |
* ββ OWNER ITEM 9 β THE WHOLE BOOK, so a stop can be added that the VIEW filtered out.
|
| 442 |
*
|
|
@@ -499,12 +533,15 @@ export function MapView({
|
|
| 499 |
* array is an honest, editable no-Zones state. */
|
| 500 |
const [zones, setZones] = useState<MapZone[] | null>(null);
|
| 501 |
const [activeZoneIds, setActiveZoneIds] = useState<ReadonlySet<string>>(new Set());
|
|
|
|
| 502 |
const [zoneEditorId, setZoneEditorId] = useState("");
|
| 503 |
const [zoneName, setZoneName] = useState("");
|
| 504 |
const [zoneColor, setZoneColor] = useState(ZONE_COLOR_CHOICES[0].value);
|
| 505 |
const [zoneGeographicField, setZoneGeographicField] = useState("");
|
| 506 |
const [zoneGeographicValue, setZoneGeographicValue] = useState("");
|
| 507 |
const [drawnZoneGeometry, setDrawnZoneGeometry] = useState<ZoneGeometry | null>(null);
|
|
|
|
|
|
|
| 508 |
const [zoneBusy, setZoneBusy] = useState(false);
|
| 509 |
const [zoneErr, setZoneErr] = useState<string | null>(null);
|
| 510 |
/**
|
|
@@ -788,13 +825,15 @@ export function MapView({
|
|
| 788 |
|
| 789 |
const loadZones = useCallback(async () => {
|
| 790 |
try {
|
| 791 |
-
const res = await fetch(
|
|
|
|
|
|
|
| 792 |
const body = res.ok ? await res.json().catch(() => null) : null;
|
| 793 |
setZones(Array.isArray(body?.zones) ? body.zones : []);
|
| 794 |
} catch {
|
| 795 |
setZones([]);
|
| 796 |
}
|
| 797 |
-
}, []);
|
| 798 |
useEffect(() => { void loadZones(); }, [loadZones]);
|
| 799 |
|
| 800 |
const activeZones = useMemo(
|
|
@@ -810,8 +849,36 @@ export function MapView({
|
|
| 810 |
() => (zones || []).find((zone) => zone.id === zoneEditorId) || null,
|
| 811 |
[zones, zoneEditorId]
|
| 812 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
const zonePolygons = useMemo(() => activeZones.flatMap((zone) => {
|
| 814 |
-
const geometry = zone.
|
|
|
|
|
|
|
| 815 |
const polygons = geometry?.type === "Polygon"
|
| 816 |
? [geometry.coordinates as number[][][]]
|
| 817 |
: geometry?.type === "MultiPolygon"
|
|
@@ -824,7 +891,7 @@ export function MapView({
|
|
| 824 |
.filter((pair) => Array.isArray(pair) && Number.isFinite(pair[0]) && Number.isFinite(pair[1]))
|
| 825 |
.map((pair) => project(pair[0], pair[1])),
|
| 826 |
})).filter((ring) => ring.points.length >= 4));
|
| 827 |
-
}), [activeZones]);
|
| 828 |
|
| 829 |
const selectionZoneGeometry = useCallback((): ZoneGeometry | null => {
|
| 830 |
if (drawnZoneGeometry) return drawnZoneGeometry;
|
|
@@ -858,6 +925,7 @@ export function MapView({
|
|
| 858 |
credentials: "same-origin",
|
| 859 |
headers: { "Content-Type": "application/json" },
|
| 860 |
body: JSON.stringify({
|
|
|
|
| 861 |
name,
|
| 862 |
color: zoneColor || existing?.color || ZONE_COLOR_CHOICES[0].value,
|
| 863 |
geometryGeoJson,
|
|
@@ -886,11 +954,7 @@ export function MapView({
|
|
| 886 |
} finally {
|
| 887 |
setZoneBusy(false);
|
| 888 |
}
|
| 889 |
-
}, [selectionZoneGeometry, zoneName, zoneColor, zoneGeographicField, selectedPids]);
|
| 890 |
-
|
| 891 |
-
const selectActiveZoneRecords = useCallback(() => {
|
| 892 |
-
if (activeZonePids.size) onSelectPids([...activeZonePids], "replace");
|
| 893 |
-
}, [activeZonePids, onSelectPids]);
|
| 894 |
|
| 895 |
const setZoneVisible = useCallback(async (zone: MapZone, visible: boolean) => {
|
| 896 |
setZoneBusy(true);
|
|
@@ -900,7 +964,7 @@ export function MapView({
|
|
| 900 |
method: "PATCH",
|
| 901 |
credentials: "same-origin",
|
| 902 |
headers: { "Content-Type": "application/json" },
|
| 903 |
-
body: JSON.stringify({ visible }),
|
| 904 |
});
|
| 905 |
const body = await res.json().catch(() => null);
|
| 906 |
if (!res.ok) {
|
|
@@ -914,7 +978,32 @@ export function MapView({
|
|
| 914 |
} finally {
|
| 915 |
setZoneBusy(false);
|
| 916 |
}
|
| 917 |
-
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 918 |
|
| 919 |
// The fit is the INITIAL view, not the projection (see mapProjection.ts).
|
| 920 |
const fit = useMemo(
|
|
@@ -1118,26 +1207,21 @@ export function MapView({
|
|
| 1118 |
* this component through `useVisibleRows`' filter -> search -> SORT -> group pipeline, so
|
| 1119 |
* "the first N" means the first N as the view is sorted -- the user's own ranking, which is
|
| 1120 |
* the only ranking this surface has. The owner asked for "the top SKU"; the map has no SKU,
|
| 1121 |
-
* and inventing one would be a plausible order nobody chose.
|
| 1122 |
-
*
|
| 1123 |
*/
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
autoRouteRef.current = true;
|
| 1134 |
-
if (selectedPids.size >= 2) return;
|
| 1135 |
-
const take = points.slice(0, ROUTE_STOP_CAP);
|
| 1136 |
-
setAutoCapped(points.length - take.length);
|
| 1137 |
-
onSelectPids(take.map((p) => p.pid), "replace");
|
| 1138 |
setRouteOn(true);
|
| 1139 |
setRouteOrder(null);
|
| 1140 |
-
}, [
|
| 1141 |
|
| 1142 |
/** The depot joins BOTH index-parallel arrays below at position zero, and only there. */
|
| 1143 |
const depotPoint = useMemo((): MapPoint | null => {
|
|
@@ -1428,6 +1512,10 @@ export function MapView({
|
|
| 1428 |
|
| 1429 |
const scheduleRouteDelivery = useCallback(async () => {
|
| 1430 |
if (!plan || !routeRecipient.trim()) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1431 |
const orderedStopIds = plan.ordered.map((point) => point.pid).filter((pid) => pid > 0);
|
| 1432 |
if (orderedStopIds.length < 2) return;
|
| 1433 |
setRouteScheduleBusy(true);
|
|
@@ -1446,11 +1534,11 @@ export function MapView({
|
|
| 1446 |
recipient: routeRecipient.trim(),
|
| 1447 |
schedule: { cron: "0 8 * * *", enabled: true },
|
| 1448 |
routeSnapshot: {
|
| 1449 |
-
//
|
| 1450 |
-
//
|
| 1451 |
-
sourceViewId:
|
| 1452 |
-
sourceViewRevision:
|
| 1453 |
-
sourceViewName:
|
| 1454 |
timezone,
|
| 1455 |
routeField: saveTarget || openedRoute,
|
| 1456 |
orderedStopIds,
|
|
@@ -1473,7 +1561,7 @@ export function MapView({
|
|
| 1473 |
} finally {
|
| 1474 |
setRouteScheduleBusy(false);
|
| 1475 |
}
|
| 1476 |
-
}, [plan, routeRecipient, openedRoute, routeCols,
|
| 1477 |
|
| 1478 |
/**
|
| 1479 |
* ββ OWNER, 2026-08-23 β **OPEN A SAVED ROUTE AND SEE IT ON THE MAP.**
|
|
@@ -1994,6 +2082,21 @@ export function MapView({
|
|
| 1994 |
|
| 1995 |
const onPointerMove = useCallback(
|
| 1996 |
(e: ReactPointerEvent<SVGSVGElement>) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1997 |
// ββ THE LAZY CAPTURE (W38-T09's fix). A gesture only becomes a drag once it has TRAVELLED,
|
| 1998 |
// so the capture is taken here rather than on pointerdown. A zero-movement click therefore
|
| 1999 |
// never captures and reaches the pin it was aimed at; a pan or a marquee captures the
|
|
@@ -2009,7 +2112,6 @@ export function MapView({
|
|
| 2009 |
if (dn && dn.id === e.pointerId && !e.currentTarget.hasPointerCapture(e.pointerId)
|
| 2010 |
&& Math.abs(e.clientX - dn.cx) + Math.abs(e.clientY - dn.cy) > 3)
|
| 2011 |
e.currentTarget.setPointerCapture(e.pointerId);
|
| 2012 |
-
const { x, y } = localPoint(e.clientX, e.clientY);
|
| 2013 |
if (drag) {
|
| 2014 |
// β W37-T30: this used to set `movedRef` UNCONDITIONALLY, and that is what swallowed a
|
| 2015 |
// shift-CLICK. Holding shift starts a marquee on pointerdown, so a shift-click is a
|
|
@@ -2040,7 +2142,7 @@ export function MapView({
|
|
| 2040 |
touchedRef.current = true;
|
| 2041 |
setView((v) => (v ? { ...v, tx: pan.tx + (x - pan.x), ty: pan.ty + (y - pan.y) } : v));
|
| 2042 |
},
|
| 2043 |
-
[drag, localPoint]
|
| 2044 |
);
|
| 2045 |
|
| 2046 |
/**
|
|
@@ -2095,6 +2197,17 @@ export function MapView({
|
|
| 2095 |
|
| 2096 |
const onPointerUp = useCallback(
|
| 2097 |
(e: ReactPointerEvent<SVGSVGElement>) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2098 |
if (drag && view) {
|
| 2099 |
// A gesture smaller than a few px is a mis-click, not a selection β
|
| 2100 |
// clearing the user's set on a stray shift-click would be its own bug.
|
|
@@ -2137,7 +2250,7 @@ export function MapView({
|
|
| 2137 |
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
| 2138 |
e.currentTarget.releasePointerCapture(e.pointerId);
|
| 2139 |
},
|
| 2140 |
-
[drag, view, points, onSelectPids]
|
| 2141 |
);
|
| 2142 |
|
| 2143 |
/**
|
|
@@ -2310,6 +2423,7 @@ export function MapView({
|
|
| 2310 |
className="cg-route-index-create"
|
| 2311 |
onClick={() => {
|
| 2312 |
setOpenedRoute("");
|
|
|
|
| 2313 |
setRouteOn(false);
|
| 2314 |
setRouteOrder(null);
|
| 2315 |
setRouteCreateOpen(true);
|
|
@@ -2431,7 +2545,7 @@ export function MapView({
|
|
| 2431 |
β EVERY route this account may see, `mine` or not β `GET /customers/route-order`
|
| 2432 |
has already applied the per-field wall, so what is listed here is exactly what
|
| 2433 |
the grid would show. Whether you may RE-SOLVE one is a different question, and
|
| 2434 |
-
it is answered by the
|
| 2435 |
{(routeCols || []).length > 0 && (
|
| 2436 |
<>
|
| 2437 |
<label className="cg-map-route-opt cg-route-field">
|
|
@@ -2588,21 +2702,6 @@ export function MapView({
|
|
| 2588 |
β NOT a `cg-cal-nodate` (the red one). Neither of these is an error: the first
|
| 2589 |
says what the panel took on the user's behalf, the second warns about a click
|
| 2590 |
that has not been spent yet. */}
|
| 2591 |
-
{/* β THE FIRST NOTE RETIRES ITSELF THE MOMENT IT STOPS BEING TRUE. `autoCapped` is
|
| 2592 |
-
what the auto-fill left out, and it stays set for as long as the panel is open
|
| 2593 |
-
-- but the sentence it prints is about the CURRENT cohort, and a person who
|
| 2594 |
-
then deselects half the pins is no longer routing "the first 100". Gated on the
|
| 2595 |
-
selection still being exactly the slice that was taken, so the disclosure
|
| 2596 |
-
disappears with the situation it describes rather than becoming a small lie in
|
| 2597 |
-
the corner of the panel. */}
|
| 2598 |
-
{autoCapped > 0 && customerRoutable.length === ROUTE_STOP_CAP && (
|
| 2599 |
-
<span className="cg-map-route-note">
|
| 2600 |
-
Routing the first {ROUTE_STOP_CAP.toLocaleString()} pins in this view, in the
|
| 2601 |
-
order the view is sorted. {autoCapped.toLocaleString()} more are mapped here:
|
| 2602 |
-
the routing service takes {ROUTE_STOP_CAP.toLocaleString()} stops in one
|
| 2603 |
-
request, so sort or filter the view to choose which ones travel.
|
| 2604 |
-
</span>
|
| 2605 |
-
)}
|
| 2606 |
{customerRoutable.length > ROUTE_STOP_CAP && (
|
| 2607 |
<span className="cg-map-route-note">
|
| 2608 |
{customerRoutable.length.toLocaleString()} stops selected. The routing service
|
|
@@ -2624,6 +2723,15 @@ export function MapView({
|
|
| 2624 |
Splitting them here to leave Save at the bottom would break the older instruction
|
| 2625 |
while honouring the newer one. ββ */}
|
| 2626 |
{/* ββ ITEM 9: add a record the view is not showing ββββββββββββββββββββββββββββββ */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2627 |
{allRows && allRows.length > 0 && (
|
| 2628 |
<RecordPicker
|
| 2629 |
label="Add a record"
|
|
@@ -2645,29 +2753,11 @@ export function MapView({
|
|
| 2645 |
vocabulary a person has to translate. The SERVER key is untouched. */}
|
| 2646 |
{plan && (
|
| 2647 |
<>
|
| 2648 |
-
<
|
| 2649 |
-
|
| 2650 |
-
|
| 2651 |
-
|
| 2652 |
-
|
| 2653 |
-
aria-label="Which visit order field to write"
|
| 2654 |
-
onChange={(e) => {
|
| 2655 |
-
setSaveTarget(e.target.value);
|
| 2656 |
-
setRouteStartPid(null);
|
| 2657 |
-
setSaveMsg(null);
|
| 2658 |
-
setSaveErr(null);
|
| 2659 |
-
}}
|
| 2660 |
-
>
|
| 2661 |
-
<option value="">New field</option>
|
| 2662 |
-
{(routeCols || [])
|
| 2663 |
-
.filter((f) => f.mine)
|
| 2664 |
-
.map((f) => (
|
| 2665 |
-
<option key={f.key} value={f.key}>
|
| 2666 |
-
{f.label}
|
| 2667 |
-
</option>
|
| 2668 |
-
))}
|
| 2669 |
-
</select>
|
| 2670 |
-
</label>
|
| 2671 |
{/* β THIS NOTE STAYS WITH THE SELECT, while items 8 and 9 send the Save
|
| 2672 |
button and the Google links to the foot. It is a statement about what
|
| 2673 |
the LIST ABOVE does not contain ("not listed above"), so it is the one
|
|
@@ -3078,32 +3168,34 @@ export function MapView({
|
|
| 3078 |
{geoControl}
|
| 3079 |
{zones !== null && (
|
| 3080 |
<div className="cg-map-zones" role="group" aria-label="Map Zones">
|
| 3081 |
-
<
|
| 3082 |
-
|
| 3083 |
-
|
| 3084 |
-
className="cg-
|
| 3085 |
-
|
| 3086 |
-
|
| 3087 |
-
|
| 3088 |
-
onChange={(event) => {
|
| 3089 |
-
const ids = [...event.currentTarget.selectedOptions].map((option) => option.value);
|
| 3090 |
-
setActiveZoneIds(new Set(ids));
|
| 3091 |
-
const chosenZone = (zones || []).find((zone) => zone.id === ids[0]);
|
| 3092 |
-
setZoneEditorId(chosenZone?.id || "");
|
| 3093 |
-
if (chosenZone) {
|
| 3094 |
-
setZoneName(chosenZone.name);
|
| 3095 |
-
setZoneColor(chosenZone.color);
|
| 3096 |
-
setZoneGeographicField(chosenZone.geographicField || "");
|
| 3097 |
-
}
|
| 3098 |
-
}}
|
| 3099 |
>
|
| 3100 |
-
{
|
| 3101 |
-
|
| 3102 |
-
|
| 3103 |
-
|
| 3104 |
-
|
| 3105 |
-
|
| 3106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3107 |
<button
|
| 3108 |
type="button"
|
| 3109 |
className="cg-btn"
|
|
@@ -3116,21 +3208,11 @@ export function MapView({
|
|
| 3116 |
>
|
| 3117 |
New Zone
|
| 3118 |
</button>
|
| 3119 |
-
<button
|
| 3120 |
-
type="button"
|
| 3121 |
-
className="cg-btn"
|
| 3122 |
-
disabled={activeZonePids.size === 0}
|
| 3123 |
-
onClick={selectActiveZoneRecords}
|
| 3124 |
-
title="Select records in the chosen visible Zones"
|
| 3125 |
-
>
|
| 3126 |
-
Select Zone records
|
| 3127 |
-
</button>
|
| 3128 |
<button
|
| 3129 |
type="button"
|
| 3130 |
className="cg-btn"
|
| 3131 |
disabled={activeZonePids.size < 2}
|
| 3132 |
onClick={() => {
|
| 3133 |
-
selectActiveZoneRecords();
|
| 3134 |
setRouteCreateOpen(true);
|
| 3135 |
setRouteOn(false);
|
| 3136 |
setRouteOrder(null);
|
|
@@ -3216,7 +3298,9 @@ export function MapView({
|
|
| 3216 |
className="cg-btn"
|
| 3217 |
disabled={zoneBusy}
|
| 3218 |
onClick={() => void saveZone(zoneEditor || undefined)}
|
| 3219 |
-
title={drawnZoneGeometry
|
|
|
|
|
|
|
| 3220 |
>
|
| 3221 |
{zoneBusy ? "Saving Zone" : zoneEditor ? "Update Zone" : "Save Zone"}
|
| 3222 |
</button>
|
|
@@ -3326,12 +3410,28 @@ export function MapView({
|
|
| 3326 |
fill={ring.zone.color}
|
| 3327 |
stroke={ring.zone.color}
|
| 3328 |
strokeWidth={hair(1.4)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3329 |
onClick={(event) => {
|
| 3330 |
event.stopPropagation();
|
| 3331 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3332 |
}}
|
| 3333 |
>
|
| 3334 |
-
<title>{`${ring.zone.name}:
|
| 3335 |
</polygon>
|
| 3336 |
))}
|
| 3337 |
{/* The planned path, UNDER the pins so it never hides a stop. Inside
|
|
|
|
| 326 |
coordinates: number[][][] | number[][][][];
|
| 327 |
};
|
| 328 |
|
| 329 |
+
/** Move a durable GeoJSON boundary without changing its topology or membership snapshot. */
|
| 330 |
+
export function translateZoneGeometry(
|
| 331 |
+
geometry: ZoneGeometry, delta: { lon: number; lat: number }
|
| 332 |
+
): ZoneGeometry {
|
| 333 |
+
const point = (pair: number[]) => [
|
| 334 |
+
Math.max(-180, Math.min(180, pair[0] + delta.lon)),
|
| 335 |
+
Math.max(-90, Math.min(90, pair[1] + delta.lat)),
|
| 336 |
+
];
|
| 337 |
+
if (geometry.type === "Polygon") {
|
| 338 |
+
return { type: "Polygon", coordinates: (geometry.coordinates as number[][][])
|
| 339 |
+
.map((ring) => ring.map(point)) };
|
| 340 |
+
}
|
| 341 |
+
return { type: "MultiPolygon", coordinates: (geometry.coordinates as number[][][][])
|
| 342 |
+
.map((polygon) => polygon.map((ring) => ring.map(point))) };
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
/** C3's durable map-domain DTO. Zones are not generic fields: geometry, visibility and the
|
| 346 |
* membership snapshot only make sense to this projection. */
|
| 347 |
interface MapZone {
|
|
|
|
| 356 |
createdAt?: string;
|
| 357 |
}
|
| 358 |
|
| 359 |
+
interface ZoneMove {
|
| 360 |
+
zone: MapZone;
|
| 361 |
+
start: Pt;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
interface RouteDelivery {
|
| 365 |
id: string;
|
| 366 |
name: string;
|
|
|
|
| 377 |
};
|
| 378 |
}
|
| 379 |
|
| 380 |
+
/** Immutable provenance supplied by the host grid for a scheduled route snapshot. */
|
| 381 |
+
export interface RouteSourceView {
|
| 382 |
+
id: string;
|
| 383 |
+
name: string;
|
| 384 |
+
revision: string;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
interface MapPoint {
|
| 388 |
pid: number;
|
| 389 |
title: string;
|
|
|
|
| 428 |
onRouteFieldCreated,
|
| 429 |
routePanelOpen,
|
| 430 |
allRows,
|
| 431 |
+
activeView,
|
| 432 |
+
tableKey,
|
| 433 |
}: {
|
| 434 |
/** DISTINCT data rows from the full pipeline, overlay edits layered β the
|
| 435 |
* calendar/kanban contract, verbatim. */
|
|
|
|
| 467 |
onGeocoded?: (writes: { pid: number; value: string }[]) => void;
|
| 468 |
onRouteFieldCreated?: (key: string) => void;
|
| 469 |
routePanelOpen: boolean;
|
| 470 |
+
/** The real active View driving this map, never a map-local synthetic id. */
|
| 471 |
+
activeView: RouteSourceView | null;
|
| 472 |
+
/** Canonical database identity for durable, table-scoped Zones. */
|
| 473 |
+
tableKey: string;
|
| 474 |
/**
|
| 475 |
* ββ OWNER ITEM 9 β THE WHOLE BOOK, so a stop can be added that the VIEW filtered out.
|
| 476 |
*
|
|
|
|
| 533 |
* array is an honest, editable no-Zones state. */
|
| 534 |
const [zones, setZones] = useState<MapZone[] | null>(null);
|
| 535 |
const [activeZoneIds, setActiveZoneIds] = useState<ReadonlySet<string>>(new Set());
|
| 536 |
+
const [zoneChooserOpen, setZoneChooserOpen] = useState(false);
|
| 537 |
const [zoneEditorId, setZoneEditorId] = useState("");
|
| 538 |
const [zoneName, setZoneName] = useState("");
|
| 539 |
const [zoneColor, setZoneColor] = useState(ZONE_COLOR_CHOICES[0].value);
|
| 540 |
const [zoneGeographicField, setZoneGeographicField] = useState("");
|
| 541 |
const [zoneGeographicValue, setZoneGeographicValue] = useState("");
|
| 542 |
const [drawnZoneGeometry, setDrawnZoneGeometry] = useState<ZoneGeometry | null>(null);
|
| 543 |
+
const [zoneMove, setZoneMove] = useState<ZoneMove | null>(null);
|
| 544 |
+
const [zoneGeometryDraft, setZoneGeometryDraft] = useState<{ id: string; geometry: ZoneGeometry } | null>(null);
|
| 545 |
const [zoneBusy, setZoneBusy] = useState(false);
|
| 546 |
const [zoneErr, setZoneErr] = useState<string | null>(null);
|
| 547 |
/**
|
|
|
|
| 825 |
|
| 826 |
const loadZones = useCallback(async () => {
|
| 827 |
try {
|
| 828 |
+
const res = await fetch(`/api/v1/geo/zones?tableKey=${encodeURIComponent(tableKey)}`, {
|
| 829 |
+
credentials: "same-origin",
|
| 830 |
+
});
|
| 831 |
const body = res.ok ? await res.json().catch(() => null) : null;
|
| 832 |
setZones(Array.isArray(body?.zones) ? body.zones : []);
|
| 833 |
} catch {
|
| 834 |
setZones([]);
|
| 835 |
}
|
| 836 |
+
}, [tableKey]);
|
| 837 |
useEffect(() => { void loadZones(); }, [loadZones]);
|
| 838 |
|
| 839 |
const activeZones = useMemo(
|
|
|
|
| 849 |
() => (zones || []).find((zone) => zone.id === zoneEditorId) || null,
|
| 850 |
[zones, zoneEditorId]
|
| 851 |
);
|
| 852 |
+
/**
|
| 853 |
+
* Zone visibility is a checkbox chooser, not a ctrl-click native multi-select. A toggle also
|
| 854 |
+
* immediately projects the Zone's durable membership onto the grid selection; removing one
|
| 855 |
+
* Zone preserves records that are still held by another checked Zone.
|
| 856 |
+
*/
|
| 857 |
+
const toggleZone = useCallback((zone: MapZone, checked: boolean) => {
|
| 858 |
+
const next = new Set(activeZoneIds);
|
| 859 |
+
if (checked) next.add(zone.id);
|
| 860 |
+
else next.delete(zone.id);
|
| 861 |
+
const stillHeld = new Set<number>();
|
| 862 |
+
for (const item of zones || []) {
|
| 863 |
+
if (!next.has(item.id)) continue;
|
| 864 |
+
for (const pid of item.memberships || []) stillHeld.add(pid);
|
| 865 |
+
}
|
| 866 |
+
const selection = new Set(selectedPids);
|
| 867 |
+
if (checked) for (const pid of zone.memberships || []) selection.add(pid);
|
| 868 |
+
else for (const pid of zone.memberships || []) if (!stillHeld.has(pid)) selection.delete(pid);
|
| 869 |
+
onSelectPids([...selection], "replace");
|
| 870 |
+
setActiveZoneIds(next);
|
| 871 |
+
if (checked) {
|
| 872 |
+
setZoneEditorId(zone.id);
|
| 873 |
+
setZoneName(zone.name);
|
| 874 |
+
setZoneColor(zone.color);
|
| 875 |
+
setZoneGeographicField(zone.geographicField || "");
|
| 876 |
+
}
|
| 877 |
+
}, [activeZoneIds, zones, selectedPids, onSelectPids]);
|
| 878 |
const zonePolygons = useMemo(() => activeZones.flatMap((zone) => {
|
| 879 |
+
const geometry = zoneGeometryDraft?.id === zone.id
|
| 880 |
+
? zoneGeometryDraft.geometry
|
| 881 |
+
: zone.geometryGeoJson;
|
| 882 |
const polygons = geometry?.type === "Polygon"
|
| 883 |
? [geometry.coordinates as number[][][]]
|
| 884 |
: geometry?.type === "MultiPolygon"
|
|
|
|
| 891 |
.filter((pair) => Array.isArray(pair) && Number.isFinite(pair[0]) && Number.isFinite(pair[1]))
|
| 892 |
.map((pair) => project(pair[0], pair[1])),
|
| 893 |
})).filter((ring) => ring.points.length >= 4));
|
| 894 |
+
}), [activeZones, zoneGeometryDraft]);
|
| 895 |
|
| 896 |
const selectionZoneGeometry = useCallback((): ZoneGeometry | null => {
|
| 897 |
if (drawnZoneGeometry) return drawnZoneGeometry;
|
|
|
|
| 925 |
credentials: "same-origin",
|
| 926 |
headers: { "Content-Type": "application/json" },
|
| 927 |
body: JSON.stringify({
|
| 928 |
+
tableKey,
|
| 929 |
name,
|
| 930 |
color: zoneColor || existing?.color || ZONE_COLOR_CHOICES[0].value,
|
| 931 |
geometryGeoJson,
|
|
|
|
| 954 |
} finally {
|
| 955 |
setZoneBusy(false);
|
| 956 |
}
|
| 957 |
+
}, [selectionZoneGeometry, zoneName, zoneColor, zoneGeographicField, selectedPids, tableKey]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 958 |
|
| 959 |
const setZoneVisible = useCallback(async (zone: MapZone, visible: boolean) => {
|
| 960 |
setZoneBusy(true);
|
|
|
|
| 964 |
method: "PATCH",
|
| 965 |
credentials: "same-origin",
|
| 966 |
headers: { "Content-Type": "application/json" },
|
| 967 |
+
body: JSON.stringify({ tableKey, visible }),
|
| 968 |
});
|
| 969 |
const body = await res.json().catch(() => null);
|
| 970 |
if (!res.ok) {
|
|
|
|
| 978 |
} finally {
|
| 979 |
setZoneBusy(false);
|
| 980 |
}
|
| 981 |
+
}, [tableKey]);
|
| 982 |
+
|
| 983 |
+
/** Persist a direct boundary drag without re-deriving membership from the current selection. */
|
| 984 |
+
const persistZoneGeometry = useCallback(async (zone: MapZone, geometryGeoJson: ZoneGeometry) => {
|
| 985 |
+
setZoneBusy(true);
|
| 986 |
+
setZoneErr(null);
|
| 987 |
+
try {
|
| 988 |
+
const res = await fetch(`/api/v1/geo/zones/${encodeURIComponent(zone.id)}`, {
|
| 989 |
+
method: "PATCH",
|
| 990 |
+
credentials: "same-origin",
|
| 991 |
+
headers: { "Content-Type": "application/json" },
|
| 992 |
+
body: JSON.stringify({ tableKey, geometryGeoJson }),
|
| 993 |
+
});
|
| 994 |
+
const body = await res.json().catch(() => null);
|
| 995 |
+
if (!res.ok) {
|
| 996 |
+
setZoneErr(body?.error?.message || "The Zone boundary could not be saved.");
|
| 997 |
+
return;
|
| 998 |
+
}
|
| 999 |
+
const saved = body?.zone as MapZone | undefined;
|
| 1000 |
+
if (saved) setZones((current) => (current || []).map((item) => item.id === saved.id ? saved : item));
|
| 1001 |
+
} catch {
|
| 1002 |
+
setZoneErr("The Zone boundary could not be saved.");
|
| 1003 |
+
} finally {
|
| 1004 |
+
setZoneBusy(false);
|
| 1005 |
+
}
|
| 1006 |
+
}, [tableKey]);
|
| 1007 |
|
| 1008 |
// The fit is the INITIAL view, not the projection (see mapProjection.ts).
|
| 1009 |
const fit = useMemo(
|
|
|
|
| 1207 |
* this component through `useVisibleRows`' filter -> search -> SORT -> group pipeline, so
|
| 1208 |
* "the first N" means the first N as the view is sorted -- the user's own ranking, which is
|
| 1209 |
* the only ranking this surface has. The owner asked for "the top SKU"; the map has no SKU,
|
| 1210 |
+
* and inventing one would be a plausible order nobody chose. Bulk selection now remains an
|
| 1211 |
+
* explicit action, so simply opening the route rail cannot alter the current selection.
|
| 1212 |
*/
|
| 1213 |
+
/**
|
| 1214 |
+
* Opening Routes is intentionally inert. This explicitly labelled control is the sole bulk
|
| 1215 |
+
* selection door: it takes every record matched by the current View (never a hidden cap of
|
| 1216 |
+
* mapped pins) and then starts the local planner.
|
| 1217 |
+
*/
|
| 1218 |
+
const selectFilteredRouteRecords = useCallback(() => {
|
| 1219 |
+
const pids = rows.map((row) => row.pid).filter((pid) => Number.isFinite(pid) && pid > 0);
|
| 1220 |
+
if (!pids.length) return;
|
| 1221 |
+
onSelectPids(pids, "replace");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
setRouteOn(true);
|
| 1223 |
setRouteOrder(null);
|
| 1224 |
+
}, [rows, onSelectPids]);
|
| 1225 |
|
| 1226 |
/** The depot joins BOTH index-parallel arrays below at position zero, and only there. */
|
| 1227 |
const depotPoint = useMemo((): MapPoint | null => {
|
|
|
|
| 1512 |
|
| 1513 |
const scheduleRouteDelivery = useCallback(async () => {
|
| 1514 |
if (!plan || !routeRecipient.trim()) return;
|
| 1515 |
+
if (!activeView?.id || !activeView.revision) {
|
| 1516 |
+
setRouteScheduleErr("Open a saved View before scheduling a route.");
|
| 1517 |
+
return;
|
| 1518 |
+
}
|
| 1519 |
const orderedStopIds = plan.ordered.map((point) => point.pid).filter((pid) => pid > 0);
|
| 1520 |
if (orderedStopIds.length < 2) return;
|
| 1521 |
setRouteScheduleBusy(true);
|
|
|
|
| 1534 |
recipient: routeRecipient.trim(),
|
| 1535 |
schedule: { cron: "0 8 * * *", enabled: true },
|
| 1536 |
routeSnapshot: {
|
| 1537 |
+
// This route's stop ids are frozen below. The View tuple is provenance, supplied by
|
| 1538 |
+
// CustomerGrid from the actual active View and its canonical configuration revision.
|
| 1539 |
+
sourceViewId: activeView.id,
|
| 1540 |
+
sourceViewRevision: activeView.revision,
|
| 1541 |
+
sourceViewName: activeView.name,
|
| 1542 |
timezone,
|
| 1543 |
routeField: saveTarget || openedRoute,
|
| 1544 |
orderedStopIds,
|
|
|
|
| 1561 |
} finally {
|
| 1562 |
setRouteScheduleBusy(false);
|
| 1563 |
}
|
| 1564 |
+
}, [plan, routeRecipient, openedRoute, routeCols, activeView, saveTarget, mapsPerLink]);
|
| 1565 |
|
| 1566 |
/**
|
| 1567 |
* ββ OWNER, 2026-08-23 β **OPEN A SAVED ROUTE AND SEE IT ON THE MAP.**
|
|
|
|
| 2082 |
|
| 2083 |
const onPointerMove = useCallback(
|
| 2084 |
(e: ReactPointerEvent<SVGSVGElement>) => {
|
| 2085 |
+
const { x, y } = localPoint(e.clientX, e.clientY);
|
| 2086 |
+
if (zoneMove && view) {
|
| 2087 |
+
if (Math.abs(x - zoneMove.start.x) + Math.abs(y - zoneMove.start.y) <= 2) return;
|
| 2088 |
+
movedRef.current = true;
|
| 2089 |
+
const origin = unproject(fromScreen(zoneMove.start, view));
|
| 2090 |
+
const current = unproject(fromScreen({ x, y }, view));
|
| 2091 |
+
setZoneGeometryDraft({
|
| 2092 |
+
id: zoneMove.zone.id,
|
| 2093 |
+
geometry: translateZoneGeometry(zoneMove.zone.geometryGeoJson, {
|
| 2094 |
+
lon: current.lon - origin.lon,
|
| 2095 |
+
lat: current.lat - origin.lat,
|
| 2096 |
+
}),
|
| 2097 |
+
});
|
| 2098 |
+
return;
|
| 2099 |
+
}
|
| 2100 |
// ββ THE LAZY CAPTURE (W38-T09's fix). A gesture only becomes a drag once it has TRAVELLED,
|
| 2101 |
// so the capture is taken here rather than on pointerdown. A zero-movement click therefore
|
| 2102 |
// never captures and reaches the pin it was aimed at; a pan or a marquee captures the
|
|
|
|
| 2112 |
if (dn && dn.id === e.pointerId && !e.currentTarget.hasPointerCapture(e.pointerId)
|
| 2113 |
&& Math.abs(e.clientX - dn.cx) + Math.abs(e.clientY - dn.cy) > 3)
|
| 2114 |
e.currentTarget.setPointerCapture(e.pointerId);
|
|
|
|
| 2115 |
if (drag) {
|
| 2116 |
// β W37-T30: this used to set `movedRef` UNCONDITIONALLY, and that is what swallowed a
|
| 2117 |
// shift-CLICK. Holding shift starts a marquee on pointerdown, so a shift-click is a
|
|
|
|
| 2142 |
touchedRef.current = true;
|
| 2143 |
setView((v) => (v ? { ...v, tx: pan.tx + (x - pan.x), ty: pan.ty + (y - pan.y) } : v));
|
| 2144 |
},
|
| 2145 |
+
[drag, localPoint, zoneMove, view]
|
| 2146 |
);
|
| 2147 |
|
| 2148 |
/**
|
|
|
|
| 2197 |
|
| 2198 |
const onPointerUp = useCallback(
|
| 2199 |
(e: ReactPointerEvent<SVGSVGElement>) => {
|
| 2200 |
+
if (zoneMove) {
|
| 2201 |
+
const geometry = zoneGeometryDraft?.id === zoneMove.zone.id
|
| 2202 |
+
? zoneGeometryDraft.geometry
|
| 2203 |
+
: null;
|
| 2204 |
+
setZoneMove(null);
|
| 2205 |
+
setZoneGeometryDraft(null);
|
| 2206 |
+
if (geometry) void persistZoneGeometry(zoneMove.zone, geometry);
|
| 2207 |
+
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
| 2208 |
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
| 2209 |
+
return;
|
| 2210 |
+
}
|
| 2211 |
if (drag && view) {
|
| 2212 |
// A gesture smaller than a few px is a mis-click, not a selection β
|
| 2213 |
// clearing the user's set on a stray shift-click would be its own bug.
|
|
|
|
| 2250 |
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
| 2251 |
e.currentTarget.releasePointerCapture(e.pointerId);
|
| 2252 |
},
|
| 2253 |
+
[drag, view, points, onSelectPids, zoneMove, zoneGeometryDraft, persistZoneGeometry]
|
| 2254 |
);
|
| 2255 |
|
| 2256 |
/**
|
|
|
|
| 2423 |
className="cg-route-index-create"
|
| 2424 |
onClick={() => {
|
| 2425 |
setOpenedRoute("");
|
| 2426 |
+
setSaveTarget("");
|
| 2427 |
setRouteOn(false);
|
| 2428 |
setRouteOrder(null);
|
| 2429 |
setRouteCreateOpen(true);
|
|
|
|
| 2545 |
β EVERY route this account may see, `mine` or not β `GET /customers/route-order`
|
| 2546 |
has already applied the per-field wall, so what is listed here is exactly what
|
| 2547 |
the grid would show. Whether you may RE-SOLVE one is a different question, and
|
| 2548 |
+
it is answered by the saved route's own edit flow, never by a second selector. */}
|
| 2549 |
{(routeCols || []).length > 0 && (
|
| 2550 |
<>
|
| 2551 |
<label className="cg-map-route-opt cg-route-field">
|
|
|
|
| 2702 |
β NOT a `cg-cal-nodate` (the red one). Neither of these is an error: the first
|
| 2703 |
says what the panel took on the user's behalf, the second warns about a click
|
| 2704 |
that has not been spent yet. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2705 |
{customerRoutable.length > ROUTE_STOP_CAP && (
|
| 2706 |
<span className="cg-map-route-note">
|
| 2707 |
{customerRoutable.length.toLocaleString()} stops selected. The routing service
|
|
|
|
| 2723 |
Splitting them here to leave Save at the bottom would break the older instruction
|
| 2724 |
while honouring the newer one. ββ */}
|
| 2725 |
{/* ββ ITEM 9: add a record the view is not showing ββββββββββββββββββββββββββββββ */}
|
| 2726 |
+
<button
|
| 2727 |
+
type="button"
|
| 2728 |
+
className="cg-btn"
|
| 2729 |
+
disabled={rows.length === 0}
|
| 2730 |
+
onClick={selectFilteredRouteRecords}
|
| 2731 |
+
title="Select every record currently matched by this View and plan the route"
|
| 2732 |
+
>
|
| 2733 |
+
Select all {rows.length.toLocaleString()} filtered records
|
| 2734 |
+
</button>
|
| 2735 |
{allRows && allRows.length > 0 && (
|
| 2736 |
<RecordPicker
|
| 2737 |
label="Add a record"
|
|
|
|
| 2753 |
vocabulary a person has to translate. The SERVER key is untouched. */}
|
| 2754 |
{plan && (
|
| 2755 |
<>
|
| 2756 |
+
<span className="cg-map-route-note">
|
| 2757 |
+
{saveTarget
|
| 2758 |
+
? `Updating ${(routeCols || []).find((route) => route.key === saveTarget)?.label || "this route"}.`
|
| 2759 |
+
: "Save this plan as a new route."}
|
| 2760 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2761 |
{/* β THIS NOTE STAYS WITH THE SELECT, while items 8 and 9 send the Save
|
| 2762 |
button and the Google links to the foot. It is a statement about what
|
| 2763 |
the LIST ABOVE does not contain ("not listed above"), so it is the one
|
|
|
|
| 3168 |
{geoControl}
|
| 3169 |
{zones !== null && (
|
| 3170 |
<div className="cg-map-zones" role="group" aria-label="Map Zones">
|
| 3171 |
+
<div className="cg-map-zone-chooser">
|
| 3172 |
+
<button
|
| 3173 |
+
type="button"
|
| 3174 |
+
className="cg-btn"
|
| 3175 |
+
aria-expanded={zoneChooserOpen}
|
| 3176 |
+
aria-controls="cg-map-zone-choices"
|
| 3177 |
+
onClick={() => setZoneChooserOpen((open) => !open)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3178 |
>
|
| 3179 |
+
Zones{activeZoneIds.size ? ` (${activeZoneIds.size.toLocaleString()})` : ""}
|
| 3180 |
+
</button>
|
| 3181 |
+
{zoneChooserOpen && (
|
| 3182 |
+
<div id="cg-map-zone-choices" className="cg-map-zone-choices" role="group" aria-label="Visible map Zones">
|
| 3183 |
+
{(zones || []).length === 0 ? (
|
| 3184 |
+
<span className="cg-map-zone-empty">No Zones yet.</span>
|
| 3185 |
+
) : (zones || []).map((zone) => (
|
| 3186 |
+
<label className="cg-map-zone-choice" key={zone.id}>
|
| 3187 |
+
<input
|
| 3188 |
+
type="checkbox"
|
| 3189 |
+
checked={activeZoneIds.has(zone.id)}
|
| 3190 |
+
onChange={(event) => toggleZone(zone, event.target.checked)}
|
| 3191 |
+
/>
|
| 3192 |
+
<span className="cg-map-zone-swatch" style={{ backgroundColor: zone.color }} aria-hidden="true" />
|
| 3193 |
+
<span>{zone.visible ? zone.name : `${zone.name} (hidden)`}</span>
|
| 3194 |
+
</label>
|
| 3195 |
+
))}
|
| 3196 |
+
</div>
|
| 3197 |
+
)}
|
| 3198 |
+
</div>
|
| 3199 |
<button
|
| 3200 |
type="button"
|
| 3201 |
className="cg-btn"
|
|
|
|
| 3208 |
>
|
| 3209 |
New Zone
|
| 3210 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3211 |
<button
|
| 3212 |
type="button"
|
| 3213 |
className="cg-btn"
|
| 3214 |
disabled={activeZonePids.size < 2}
|
| 3215 |
onClick={() => {
|
|
|
|
| 3216 |
setRouteCreateOpen(true);
|
| 3217 |
setRouteOn(false);
|
| 3218 |
setRouteOrder(null);
|
|
|
|
| 3298 |
className="cg-btn"
|
| 3299 |
disabled={zoneBusy}
|
| 3300 |
onClick={() => void saveZone(zoneEditor || undefined)}
|
| 3301 |
+
title={drawnZoneGeometry
|
| 3302 |
+
? (zoneEditor ? "Replace this Zone boundary with the drawn shape" : "Save the drawn Zone")
|
| 3303 |
+
: "Save a Zone around the selected records"}
|
| 3304 |
>
|
| 3305 |
{zoneBusy ? "Saving Zone" : zoneEditor ? "Update Zone" : "Save Zone"}
|
| 3306 |
</button>
|
|
|
|
| 3410 |
fill={ring.zone.color}
|
| 3411 |
stroke={ring.zone.color}
|
| 3412 |
strokeWidth={hair(1.4)}
|
| 3413 |
+
onPointerDown={(event) => {
|
| 3414 |
+
if (event.button !== 0 || !view) return;
|
| 3415 |
+
event.stopPropagation();
|
| 3416 |
+
movedRef.current = false;
|
| 3417 |
+
const svg = event.currentTarget.ownerSVGElement;
|
| 3418 |
+
svg?.setPointerCapture(event.pointerId);
|
| 3419 |
+
setZoneMove({
|
| 3420 |
+
zone: ring.zone,
|
| 3421 |
+
start: localPoint(event.clientX, event.clientY),
|
| 3422 |
+
});
|
| 3423 |
+
}}
|
| 3424 |
onClick={(event) => {
|
| 3425 |
event.stopPropagation();
|
| 3426 |
+
if (movedRef.current) {
|
| 3427 |
+
movedRef.current = false;
|
| 3428 |
+
return;
|
| 3429 |
+
}
|
| 3430 |
+
if (!activeZoneIds.has(ring.zone.id)) toggleZone(ring.zone, true);
|
| 3431 |
+
else onSelectPids(ring.zone.memberships, "replace");
|
| 3432 |
}}
|
| 3433 |
>
|
| 3434 |
+
<title>{`${ring.zone.name}: click to select records, drag to move boundary`}</title>
|
| 3435 |
</polygon>
|
| 3436 |
))}
|
| 3437 |
{/* The planned path, UNDER the pins so it never hides a stop. Inside
|
web/src/customer-grid/map.css
CHANGED
|
@@ -255,7 +255,55 @@
|
|
| 255 |
font: 500 var(--lp-fs-3xs)/1.35 Inter, system-ui, sans-serif;
|
| 256 |
}
|
| 257 |
|
| 258 |
-
.cg-map-zone-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
/* --- the address lookup (T37) ---------------------------------------------
|
| 261 |
*
|
|
|
|
| 255 |
font: 500 var(--lp-fs-3xs)/1.35 Inter, system-ui, sans-serif;
|
| 256 |
}
|
| 257 |
|
| 258 |
+
.cg-map-zone-chooser {
|
| 259 |
+
position: relative;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.cg-map-zone-choices {
|
| 263 |
+
position: absolute;
|
| 264 |
+
z-index: 4;
|
| 265 |
+
top: calc(100% + 4px);
|
| 266 |
+
left: 0;
|
| 267 |
+
display: grid;
|
| 268 |
+
width: min(280px, calc(100vw - 32px));
|
| 269 |
+
max-height: 248px;
|
| 270 |
+
overflow-y: auto;
|
| 271 |
+
padding: 5px;
|
| 272 |
+
border: 1px solid var(--lp-line);
|
| 273 |
+
border-radius: 6px;
|
| 274 |
+
background: var(--lp-panel, #fff);
|
| 275 |
+
box-shadow: 0 8px 20px rgba(24, 39, 55, 0.14);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.cg-map-zone-choice {
|
| 279 |
+
display: grid;
|
| 280 |
+
grid-template-columns: 14px 10px minmax(0, 1fr);
|
| 281 |
+
align-items: center;
|
| 282 |
+
gap: 7px;
|
| 283 |
+
min-height: 28px;
|
| 284 |
+
padding: 3px 5px;
|
| 285 |
+
border-radius: 4px;
|
| 286 |
+
color: var(--lp-ink);
|
| 287 |
+
cursor: pointer;
|
| 288 |
+
font: 500 var(--lp-fs-2xs)/1.35 Inter, system-ui, sans-serif;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.cg-map-zone-choice:hover { background: var(--lp-hover, #f3f6f9); }
|
| 292 |
+
|
| 293 |
+
.cg-map-zone-choice input { margin: 0; }
|
| 294 |
+
|
| 295 |
+
.cg-map-zone-swatch {
|
| 296 |
+
width: 9px;
|
| 297 |
+
height: 9px;
|
| 298 |
+
border: 1px solid color-mix(in srgb, var(--lp-ink) 30%, transparent);
|
| 299 |
+
border-radius: 50%;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.cg-map-zone-empty {
|
| 303 |
+
padding: 7px 6px;
|
| 304 |
+
color: var(--lp-muted);
|
| 305 |
+
font: 400 var(--lp-fs-2xs)/1.35 Inter, system-ui, sans-serif;
|
| 306 |
+
}
|
| 307 |
|
| 308 |
/* --- the address lookup (T37) ---------------------------------------------
|
| 309 |
*
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -104,6 +104,33 @@ export type AiCellState = "agent" | "human" | "stale" | "error";
|
|
| 104 |
|
| 105 |
export type FieldSource = "odoo" | "overlay";
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
/**
|
| 108 |
* R13 β the languages a `code` column may declare. Mirrors `aios_grid.CODE_LANGUAGES`
|
| 109 |
* name-for-name; the fields-contract gate diffs the two, because a language the client offers
|
|
@@ -2150,17 +2177,8 @@ export interface Field {
|
|
| 2150 |
* The host may add calendar-specific keys over time; this small, forward-compatible core is
|
| 2151 |
* enough for the editor to describe the next occurrence without inventing a second cell value.
|
| 2152 |
*/
|
| 2153 |
-
|
| 2154 |
-
|
| 2155 |
-
interval?: number;
|
| 2156 |
-
weekdays?: number[];
|
| 2157 |
-
skipHolidays?: boolean;
|
| 2158 |
-
holidayCalendar?: "us";
|
| 2159 |
-
/** Server-computed next dates win over the browser preview when supplied. */
|
| 2160 |
-
preview?: string[];
|
| 2161 |
-
};
|
| 2162 |
-
/** The server-authoritative next date for a recurring Date field. */
|
| 2163 |
-
nextDate?: string;
|
| 2164 |
/**
|
| 2165 |
* Whether select-family choices render with coloured pills. Absent means true so legacy
|
| 2166 |
* fields keep their existing appearance; an explicit false is the user's opt-out.
|
|
@@ -2409,6 +2427,9 @@ export interface Field {
|
|
| 2409 |
reciprocal?: string;
|
| 2410 |
/** Airtable's `prefersSingleRecordLink` */
|
| 2411 |
single?: boolean;
|
|
|
|
|
|
|
|
|
|
| 2412 |
};
|
| 2413 |
/**
|
| 2414 |
* β 2026-08-07 β an AGGREGATE over the rows a `link` column resolves (Airtable's `rollup`).
|
|
@@ -3179,6 +3200,23 @@ export function rowsAuthoritative(sel: {
|
|
| 3179 |
return sel.current === undefined || sel.rows.length > 0;
|
| 3180 |
}
|
| 3181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3182 |
/**
|
| 3183 |
* β Wave-20 owner item 2 β **WHICH COLUMN A FILTER RULE IS ABOUT, and it is not always
|
| 3184 |
* `rule.colId`.**
|
|
|
|
| 104 |
|
| 105 |
export type FieldSource = "odoo" | "overlay";
|
| 106 |
|
| 107 |
+
/** The durable Date-field schedule. The API owns validation and every preview calculation. */
|
| 108 |
+
export type DateRecurrenceRule = {
|
| 109 |
+
kind: "daily" | "weekly" | "monthly" | "yearly" | "holiday";
|
| 110 |
+
interval?: number;
|
| 111 |
+
/** Monday = 0, matching the server's ISO weekday contract. */
|
| 112 |
+
weekdays?: number[];
|
| 113 |
+
/** Monthly day (1..31), or yearly month/day. */
|
| 114 |
+
day?: number;
|
| 115 |
+
month?: number;
|
| 116 |
+
/** Monthly first..fifth (1..5), or last (-1), plus an ISO weekday. */
|
| 117 |
+
ordinal?: -1 | 1 | 2 | 3 | 4 | 5;
|
| 118 |
+
weekday?: number;
|
| 119 |
+
/** Only an API-supplied public calendar can be saved. */
|
| 120 |
+
calendar?: "US" | "CA";
|
| 121 |
+
/** -1 = day before, 0 = day on, 1 = day after. */
|
| 122 |
+
offset?: -1 | 0 | 1;
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
export type DateRecurrence = {
|
| 126 |
+
startDate: string;
|
| 127 |
+
rules: DateRecurrenceRule[];
|
| 128 |
+
previewCount?: number;
|
| 129 |
+
/** Derived by the server; never written as an independent schedule value. */
|
| 130 |
+
nextDate?: string | null;
|
| 131 |
+
preview?: string[];
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
/**
|
| 135 |
* R13 β the languages a `code` column may declare. Mirrors `aios_grid.CODE_LANGUAGES`
|
| 136 |
* name-for-name; the fields-contract gate diffs the two, because a language the client offers
|
|
|
|
| 2177 |
* The host may add calendar-specific keys over time; this small, forward-compatible core is
|
| 2178 |
* enough for the editor to describe the next occurrence without inventing a second cell value.
|
| 2179 |
*/
|
| 2180 |
+
/** `null` is an explicit clear in a field-upsert request; persisted payloads omit it. */
|
| 2181 |
+
recurrence?: DateRecurrence | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2182 |
/**
|
| 2183 |
* Whether select-family choices render with coloured pills. Absent means true so legacy
|
| 2184 |
* fields keep their existing appearance; an explicit false is the user's opt-out.
|
|
|
|
| 2427 |
reciprocal?: string;
|
| 2428 |
/** Airtable's `prefersSingleRecordLink` */
|
| 2429 |
single?: boolean;
|
| 2430 |
+
/** W43: how the relation is populated. Manual keeps the ordinary record picker available;
|
| 2431 |
+
* automatic is retained as schema state for a configured/engine-driven relation. */
|
| 2432 |
+
selectionMode?: "manual" | "automatic";
|
| 2433 |
};
|
| 2434 |
/**
|
| 2435 |
* β 2026-08-07 β an AGGREGATE over the rows a `link` column resolves (Airtable's `rollup`).
|
|
|
|
| 3200 |
return sel.current === undefined || sel.rows.length > 0;
|
| 3201 |
}
|
| 3202 |
|
| 3203 |
+
|
| 3204 |
+
/**
|
| 3205 |
+
* Whether an embedded Link picker should adopt its parent's selection command.
|
| 3206 |
+
*
|
| 3207 |
+
* A parent prop that has not changed is merely the stale value from before the user's checkbox
|
| 3208 |
+
* click; applying it would erase that click before the child reports upward. The pure predicate
|
| 3209 |
+
* lives here so the controlled-selection race has a node-runnable regression rather than a JSX
|
| 3210 |
+
* comment that can drift.
|
| 3211 |
+
*/
|
| 3212 |
+
export function shouldAdoptEmbeddedSelection(
|
| 3213 |
+
priorParentKey: string,
|
| 3214 |
+
parentKey: string,
|
| 3215 |
+
localKey: string
|
| 3216 |
+
): boolean {
|
| 3217 |
+
return priorParentKey !== parentKey && localKey !== parentKey;
|
| 3218 |
+
}
|
| 3219 |
+
|
| 3220 |
/**
|
| 3221 |
* β Wave-20 owner item 2 β **WHICH COLUMN A FILTER RULE IS ABOUT, and it is not always
|
| 3222 |
* `rule.colId`.**
|
web/src/shell/Shell.tsx
CHANGED
|
@@ -1136,10 +1136,6 @@ function ShellFrame() {
|
|
| 1136 |
const expandRail = useCallback(() => {
|
| 1137 |
setNavCollapsed(false);
|
| 1138 |
}, []);
|
| 1139 |
-
/** β¦and every deliberate fold through here. The AUTOMATIC fold has its own handler, below. */
|
| 1140 |
-
const collapseRail = useCallback(() => {
|
| 1141 |
-
setNavCollapsed(true);
|
| 1142 |
-
}, []);
|
| 1143 |
// Owner item 3 (2026-07-31) β the collapsed strip names its icons on hover. A real DOM
|
| 1144 |
// tooltip positioned FIXED beside the rail: the rail scrolls and clips (overflow), so a
|
| 1145 |
// CSS ::after inside it could never escape; and it is pointer-events:none by standing rule
|
|
@@ -1224,6 +1220,11 @@ function ShellFrame() {
|
|
| 1224 |
setRail((s) => ({ ...s, at: 0 }));
|
| 1225 |
setDbQuery("");
|
| 1226 |
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1227 |
/** Drill into the database list. RESUMES the walk rather than restarting it β see `closeDbFly`. */
|
| 1228 |
const openData = useCallback(() => {
|
| 1229 |
setRail((s) =>
|
|
@@ -1292,13 +1293,13 @@ function ShellFrame() {
|
|
| 1292 |
const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
|
| 1293 |
// ββ W38-T04 (R9) β THE AUTOMATIC FOLD IS UNCONDITIONAL, AND THAT IS THE WHOLE RULE.
|
| 1294 |
// `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and EVERY view switch;
|
| 1295 |
-
// this is its only listener, and it
|
| 1296 |
// `railHeldOpen` guard stood here and is deleted (see the block by `expandRail` for why the
|
| 1297 |
// ref went with it). β ANY early return, condition or stored preference added between this
|
| 1298 |
-
// line and the
|
| 1299 |
// `verify_ui.py`'s `W38-T04 unconditional` leg exists to go red the moment one appears.
|
| 1300 |
const onNavMinimize = () => {
|
| 1301 |
-
|
| 1302 |
};
|
| 1303 |
// C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING β
|
| 1304 |
// `parseShareRequest` fail-closes on an unknown kind rather than launching a
|
|
@@ -1317,7 +1318,7 @@ function ShellFrame() {
|
|
| 1317 |
window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
| 1318 |
window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen);
|
| 1319 |
};
|
| 1320 |
-
}, []);
|
| 1321 |
|
| 1322 |
// The toast clears itself; nothing else depends on it having been seen.
|
| 1323 |
useEffect(() => {
|
|
@@ -2272,7 +2273,7 @@ function ShellFrame() {
|
|
| 2272 |
β THE TWO CLASSES COMPOSE, in the right order: while `/nav` is in flight the skeleton
|
| 2273 |
rule hides `.shell-level` too, so a rail cannot paint a database list over a loading
|
| 2274 |
frame. */}
|
| 2275 |
-
<nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "") + (dbAt ? " is-datalevel" : "")}>
|
| 2276 |
{/* ββ W33-T75 β THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
|
| 2277 |
(`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
|
| 2278 |
and hiding them β rather than not rendering them β is deliberate: the flyout portal,
|
|
@@ -2609,7 +2610,7 @@ function ShellFrame() {
|
|
| 2609 |
spells the string it is retiring can hold a grep-gate green over an empty file.
|
| 2610 |
`verify_home` strips comments before it looks, so this one could have quoted them
|
| 2611 |
safely today; the next gate to read this file may not. */}
|
| 2612 |
-
{dbAt ? (
|
| 2613 |
<div className="shell-level">
|
| 2614 |
{/* β THE PATH HEADER (owner instruction 29) β *"a back '<' and forward '>'
|
| 2615 |
button so we can go to the previous path or next path that we have already
|
|
|
|
| 1136 |
const expandRail = useCallback(() => {
|
| 1137 |
setNavCollapsed(false);
|
| 1138 |
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1139 |
// Owner item 3 (2026-07-31) β the collapsed strip names its icons on hover. A real DOM
|
| 1140 |
// tooltip positioned FIXED beside the rail: the rail scrolls and clips (overflow), so a
|
| 1141 |
// CSS ::after inside it could never escape; and it is pointer-events:none by standing rule
|
|
|
|
| 1220 |
setRail((s) => ({ ...s, at: 0 }));
|
| 1221 |
setDbQuery("");
|
| 1222 |
}, []);
|
| 1223 |
+
/** A folded rail is always the global rail, never a squeezed database path. */
|
| 1224 |
+
const collapseRail = useCallback(() => {
|
| 1225 |
+
closeDbFly();
|
| 1226 |
+
setNavCollapsed(true);
|
| 1227 |
+
}, [closeDbFly]);
|
| 1228 |
/** Drill into the database list. RESUMES the walk rather than restarting it β see `closeDbFly`. */
|
| 1229 |
const openData = useCallback(() => {
|
| 1230 |
setRail((s) =>
|
|
|
|
| 1293 |
const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
|
| 1294 |
// ββ W38-T04 (R9) β THE AUTOMATIC FOLD IS UNCONDITIONAL, AND THAT IS THE WHOLE RULE.
|
| 1295 |
// `NAV_MINIMIZE_EVENT` fires from `CustomerGrid` on EVERY cell click and EVERY view switch;
|
| 1296 |
+
// this is its only listener, and it folds, every time, with nothing in front of it. W34-T12's
|
| 1297 |
// `railHeldOpen` guard stood here and is deleted (see the block by `expandRail` for why the
|
| 1298 |
// ref went with it). β ANY early return, condition or stored preference added between this
|
| 1299 |
+
// line and the fold re-creates the behaviour owner item 3 asked to be fixed FOREVER β and
|
| 1300 |
// `verify_ui.py`'s `W38-T04 unconditional` leg exists to go red the moment one appears.
|
| 1301 |
const onNavMinimize = () => {
|
| 1302 |
+
collapseRail();
|
| 1303 |
};
|
| 1304 |
// C-SHARE: the rail asks, the frame opens. A malformed detail opens NOTHING β
|
| 1305 |
// `parseShareRequest` fail-closes on an unknown kind rather than launching a
|
|
|
|
| 1318 |
window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
|
| 1319 |
window.removeEventListener(SHARE_OPEN_EVENT, onShareOpen);
|
| 1320 |
};
|
| 1321 |
+
}, [collapseRail]);
|
| 1322 |
|
| 1323 |
// The toast clears itself; nothing else depends on it having been seen.
|
| 1324 |
useEffect(() => {
|
|
|
|
| 2273 |
β THE TWO CLASSES COMPOSE, in the right order: while `/nav` is in flight the skeleton
|
| 2274 |
rule hides `.shell-level` too, so a rail cannot paint a database list over a loading
|
| 2275 |
frame. */}
|
| 2276 |
+
<nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "") + (!navCollapsed && dbAt ? " is-datalevel" : "")}>
|
| 2277 |
{/* ββ W33-T75 β THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
|
| 2278 |
(`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
|
| 2279 |
and hiding them β rather than not rendering them β is deliberate: the flyout portal,
|
|
|
|
| 2610 |
spells the string it is retiring can hold a grep-gate green over an empty file.
|
| 2611 |
`verify_home` strips comments before it looks, so this one could have quoted them
|
| 2612 |
safely today; the next gate to read this file may not. */}
|
| 2613 |
+
{!navCollapsed && dbAt ? (
|
| 2614 |
<div className="shell-level">
|
| 2615 |
{/* β THE PATH HEADER (owner instruction 29) β *"a back '<' and forward '>'
|
| 2616 |
button so we can go to the previous path or next path that we have already
|
web/wiring_rows/wave43_shell_repair.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wave 43 continuation shell-repair β DatePicker consumer wiring.
|
| 2 |
+
|
| 3 |
+
The DatePicker implementation and CustomerGrid mount are owned by the grid lane. This module
|
| 4 |
+
records their completed cross-fence contract in the shared WIRINGS map, so the coverage ratchet
|
| 5 |
+
cannot mistake a mounted calendar for an unowned component again.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
ROWS: list[tuple[str, str, str, str, str]] = [
|
| 11 |
+
("W43C-T02 DatePicker is DECLARED and MOUNTED by the grid",
|
| 12 |
+
"customer-grid/DatePicker.tsx", r"export function DatePicker\(",
|
| 13 |
+
"customer-grid/CustomerGrid.tsx", r"<DatePicker"),
|
| 14 |
+
("W43C-T02 a picked date writes through the grid's ordinary record patch door",
|
| 15 |
+
"customer-grid/DatePicker.tsx", r"onChange: \(value: string\) => void;",
|
| 16 |
+
"customer-grid/CustomerGrid.tsx",
|
| 17 |
+
r'onChange=\{\(value\) => patchAndRecord\(datePicker\.pid, \{ \[dateField\.key\]: value \}, "a date"\)\}'),
|
| 18 |
+
("W43C-T02 recurrence is a required DatePicker contract and persists the field definition",
|
| 19 |
+
"customer-grid/DatePicker.tsx",
|
| 20 |
+
r'onRecurrence: \(recurrence: NonNullable<Field\["recurrence"\]>, nextDate: string \| undefined\) => void;',
|
| 21 |
+
"customer-grid/CustomerGrid.tsx",
|
| 22 |
+
r'onRecurrence=\{\(recurrence, nextDate\) => saveField\(\{ \.\.\.dateField, recurrence, nextDate \}\)\}'),
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
DELETIONS: list[tuple[str, str, str, str]] = []
|