fsanyoto commited on
Commit
3c7207c
Β·
verified Β·
1 Parent(s): 2358748

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "v49 (9a14c01)",
3
  "releases": [
4
  {
5
  "version": "v49",
 
1
  {
2
+ "current": "e6e361c",
3
  "releases": [
4
  {
5
  "version": "v49",
VERSION CHANGED
@@ -1 +1 @@
1
- v49 (9a14c01)
 
1
+ e6e361c
api/routes_customers.py CHANGED
@@ -883,6 +883,50 @@ def _route_defs(session: Session):
883
  return {k: v for k, v in defs.items() if k not in hide}
884
 
885
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
886
  @router.get("/customers/route-order")
887
  def route_order_list(session: Session = Depends(module_gate(MODULE))):
888
  """The route-order columns this session may see, with the fingerprint each was solved from.
@@ -1087,6 +1131,36 @@ def route_order_write(body: dict = Body(default=None),
1087
  "source": "overlay",
1088
  "shared": True,
1089
  "kind": ROUTE_KIND,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  # ⭐⭐ THE PER-FIELD GRANT MARKER (T16). It is an EXPLICIT write-once declaration and it is
1091
  # what makes the wall fail CLOSED: a grant wall has the opposite absence-polarity to a
1092
  # deny wall, so keying visibility on "does a grant record exist" would publish this column
@@ -1171,35 +1245,61 @@ def route_order_delete(field_key: str,
1171
  """
1172
  from core import shared_overlay
1173
  import core.perm_scope as perm_scope
 
1174
  import aios_grid
1175
 
1176
  key = str(field_key or "").strip()
1177
  all_defs = shared_fields(st=session.runtime) or {}
1178
  defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None
 
 
 
 
 
 
1179
  unknown = err(404, "unknown_field",
1180
  "that column is not a route order column on this database")
1181
- if not defn:
1182
  raise unknown
1183
  # The wall FIRST, so a hidden column is indistinguishable from an absent one.
1184
- if key in perm_scope.hidden_keys(
 
 
 
 
 
1185
  session.user, MODULE,
1186
  _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime):
1187
  raise unknown
1188
  # β›” KIND-CHECKED, NOT PREFIX-CHECKED. `ROUTE_KEY_PREFIX` is a naming convention and a
1189
  # convention is not a declaration (the comment on `ROUTE_KIND` says so); a door that deleted
1190
  # by prefix would happily drop a shared column somebody else's feature owns.
1191
- if defn.get("kind") != ROUTE_KIND:
1192
  raise err(400, "not_a_route_column",
1193
  "that column is shared but it is not a route order column, so this door will "
1194
  "not remove it")
1195
- owner = str(defn.get("createdBy") or "")
1196
  if not session.admin and owner != session.uname:
1197
  raise err(403, "forbidden",
1198
  f"a route order column can be removed by the person who planned it or by an "
1199
  f"administrator. This one was planned by {owner or 'somebody else'}, and "
1200
  f"removing it would delete the visit numbers for every account at once")
1201
 
1202
- dropped = shared_overlay.drop_field(_shared_key(), key, st=session.runtime)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1203
  # ⭐⭐ THE GRANT DIES WITH THE COLUMN. `route_order_write` claims
1204
  # `shares.field_oid(SHARE_TOPIC, key)` on create, so skipping this would leave a grant record
1205
  # pointing at nothing β€” a ghost in every receiver's "Shared with me" that 404s on open, and
@@ -1212,7 +1312,7 @@ def route_order_delete(field_key: str,
1212
  shares.drop_objects([("field", shares.field_oid(SHARE_TOPIC, key))], st=session.runtime)
1213
  except Exception: # noqa: BLE001
1214
  pass
1215
- return {"ok": True, "field": key, "dropped": bool(dropped)}
1216
 
1217
 
1218
  @router.patch("/customers/route-order/{field_key}")
 
883
  return {k: v for k, v in defs.items() if k not in hide}
884
 
885
 
886
+ def _own_route_fork(session: Session, key: str):
887
+ """This caller's PRIVATE copy of a route column, if a per-user write ever forked one.
888
+
889
+ β›”β›” THE FORK IS REAL, AND IT IS WHAT BROKE DELETE. Traced through `grid_events.field_upsert`
890
+ rather than assumed: `shared_field` is `bool(shared_prior.get('custom'))` and a route
891
+ definition carries no `custom` key, so the shared branch does not take it; the key is neither
892
+ `custom_` nor `measure_`; it IS in the merged contract, so the `key in field_by_key` branch
893
+ does β€” and that branch's own body is what writes `note`, `format` and `agg`, landing them in
894
+ THIS USER'S field definitions through `TableStore.save_field`. `_merge_shared_fields` then
895
+ SKIPS the tenant-wide definition, because a key the contract already declares wins, and from
896
+ that moment the person is reading a private copy of a shared column.
897
+
898
+ ⚠ WHICH DOORS STILL FORK, AS OF 2026-08-23, because "the fork is fixed" would be too broad.
899
+ The Description and the Name are CLOSED: owner item 6 routed `onNote` and `onRename` to
900
+ `route_order_rename` below. The Edit-field pane's **Format** row (`onFormat`, unconditional)
901
+ and its **Summary** row (`onAggregate`, whose `isUserTable` arm is false on this registry
902
+ topic) both still reach `saveField`, so either one still mints a fork. They are left open on
903
+ purpose: closing them needs this door to accept `format` and `agg`, which is a wider change
904
+ than the delete the owner reported. This function is what keeps that recoverable rather than
905
+ permanent.
906
+
907
+ β›” WHY THAT MADE THE DELETE 404 RATHER THAN MERELY MISBEHAVE. The first delete found the
908
+ tenant-wide definition, dropped it and answered 200 β€” and the column came straight back on the
909
+ next read, because the fork was still declaring it. The second attempt found nothing in
910
+ `shared_fields` and answered *"that column is not a route order column on this database"*
911
+ about a column sitting on screen. Owner, 2026-08-23: *"I can't even delete the Field route
912
+ now?"* Both halves are answered here: a fork is FOUND, and `route_order_delete` drops it WITH
913
+ the definition rather than leaving it to redeclare the column.
914
+
915
+ ⚠ `kind` IS THE TEST, NEVER MERE PRESENCE. `TableStore.workspace` merges the tenant-wide
916
+ column summary over this stratum and will mint a bare `{'agg': ...}` entry for a key the user
917
+ has never touched (W36-T25, whose own comment says it must be able to CREATE an entry).
918
+ Reading presence would call that stub a fork and scrub a column nobody had forked.
919
+ """
920
+ try:
921
+ ws = _customer_table(session).workspace(session.uname, consume_corrections=False)
922
+ except Exception: # noqa: BLE001
923
+ return None
924
+ entry = (ws.get("fields") or {}).get(key)
925
+ if not isinstance(entry, dict) or entry.get("kind") != ROUTE_KIND:
926
+ return None
927
+ return entry
928
+
929
+
930
  @router.get("/customers/route-order")
931
  def route_order_list(session: Session = Depends(module_gate(MODULE))):
932
  """The route-order columns this session may see, with the fingerprint each was solved from.
 
1131
  "source": "overlay",
1132
  "shared": True,
1133
  "kind": ROUTE_KIND,
1134
+ # ⭐⭐ OWNER, 2026-08-23 β€” A NEW ROUTE READS AS **PRIVATE**, NEVER "Shared with everyone".
1135
+ #
1136
+ # Owner: *"Right now the Route is 'Shared with everyone' in terms of the Field status when
1137
+ # i check the Route Field. It should always default to private first."*
1138
+ #
1139
+ # β›” A CLASSIFICATION FIX, NOT A TIGHTENING, AND THE DIFFERENCE IS THE WHOLE POINT. This
1140
+ # column was ALREADY private in the only sense that governs a reader: `FIELD_GRANT_MARK`
1141
+ # plus the empty-entry grant claimed below means creator-and-admin and nobody else. What
1142
+ # was wrong was the WORD ON SCREEN. `FieldsHidePanel` sections the field list by
1143
+ # `types.fieldEditMode`, which is `cleanFieldPermissions(field.permissions,
1144
+ # "collaborative")` β€” so a definition carrying NO permissions bag fell to that fallback
1145
+ # and was filed under "Shared with everyone" while being shared with nobody at all.
1146
+ #
1147
+ # ⚠ AND IT MOVES NO WALL, CHECKED RATHER THAN REASONED. `grid_events._may_edit_field_value`
1148
+ # short-circuits on `definition.get('shared') or definition.get('granted')` BEFORE it reads
1149
+ # `stored_permissions`, so the creator's own rank edits and `_patch_route_ranks`' swap are
1150
+ # decided by `_field_share_role` and not by this bag; the client twin `mayEditField` takes
1151
+ # the same branch in the same order. `field_permissions.migrate_legacy_fields` only ever
1152
+ # rewrites a PER-USER field carrying `custom: True`, which a route definition is not, so it
1153
+ # leaves this key alone. `fieldEditMode` has exactly one other consumer: none.
1154
+ #
1155
+ # β›” NO MIGRATION, AND IT IS THE SAME DELIBERATE CHOICE `_next_route_label` MAKES ABOUT
1156
+ # `Destination N`. This dict is rebuilt whole on every write, so a column minted before
1157
+ # today gains the bag on its next RE-SOLVE and not one moment sooner β€” until then it
1158
+ # keeps reading "Shared with everyone" in the Hide fields panel while being shared with
1159
+ # nobody. Backfilling every stored route definition on a read is a write nobody asked
1160
+ # for, on a tenant-wide stratum, triggered by opening a page; re-solving is one click and
1161
+ # it is the click the owner is already making. Stated here rather than left to be
1162
+ # rediscovered as "the fix did not work".
1163
+ "permissions": {"edit": "personal"},
1164
  # ⭐⭐ THE PER-FIELD GRANT MARKER (T16). It is an EXPLICIT write-once declaration and it is
1165
  # what makes the wall fail CLOSED: a grant wall has the opposite absence-polarity to a
1166
  # deny wall, so keying visibility on "does a grant record exist" would publish this column
 
1245
  """
1246
  from core import shared_overlay
1247
  import core.perm_scope as perm_scope
1248
+ import core.table_store as table_store
1249
  import aios_grid
1250
 
1251
  key = str(field_key or "").strip()
1252
  all_defs = shared_fields(st=session.runtime) or {}
1253
  defn = all_defs.get(key) if isinstance(all_defs.get(key), dict) else None
1254
+ # ⭐⭐ OWNER, 2026-08-23 β€” A PRIVATE FORK IS ALSO A COLUMN TO DELETE. See `_own_route_fork`
1255
+ # above: once any per-user write has forked this key, the fork is what the person is looking
1256
+ # at and it OUTLIVES a drop of the tenant-wide definition. Answering 404 about a column that
1257
+ # is on screen is exactly the refusal the owner hit.
1258
+ fork = _own_route_fork(session, key)
1259
+ subject = defn or fork
1260
  unknown = err(404, "unknown_field",
1261
  "that column is not a route order column on this database")
1262
+ if not subject:
1263
  raise unknown
1264
  # The wall FIRST, so a hidden column is indistinguishable from an absent one.
1265
+ # ⚠ ASKED ABOUT THE TENANT-WIDE DEFINITION ONLY, and that is not a hole. The wall reads
1266
+ # `FIELD_GRANT_MARK` off the MERGED contract; with the definition already gone there is no
1267
+ # marked column left for it to hide, and a fork lives in this caller's OWN stratum, which no
1268
+ # grant has ever governed. Not asking when there is nothing to ask about beats asking of a
1269
+ # contract that no longer carries the key and reading the empty answer as "not hidden".
1270
+ if defn and key in perm_scope.hidden_keys(
1271
  session.user, MODULE,
1272
  _merge_shared_fields(list(aios_grid.FIELDS), all_defs), st=session.runtime):
1273
  raise unknown
1274
  # β›” KIND-CHECKED, NOT PREFIX-CHECKED. `ROUTE_KEY_PREFIX` is a naming convention and a
1275
  # convention is not a declaration (the comment on `ROUTE_KIND` says so); a door that deleted
1276
  # by prefix would happily drop a shared column somebody else's feature owns.
1277
+ if subject.get("kind") != ROUTE_KIND:
1278
  raise err(400, "not_a_route_column",
1279
  "that column is shared but it is not a route order column, so this door will "
1280
  "not remove it")
1281
+ owner = str(subject.get("createdBy") or "")
1282
  if not session.admin and owner != session.uname:
1283
  raise err(403, "forbidden",
1284
  f"a route order column can be removed by the person who planned it or by an "
1285
  f"administrator. This one was planned by {owner or 'somebody else'}, and "
1286
  f"removing it would delete the visit numbers for every account at once")
1287
 
1288
+ dropped = bool(defn) and shared_overlay.drop_field(_shared_key(), key, st=session.runtime)
1289
+ # β›”β›” AND THE FORK GOES IN THE SAME CALL. Dropping only the tenant-wide definition is what
1290
+ # made the first delete look like it had worked and then undo itself: the fork still declares
1291
+ # the column, `_merge_shared_fields` still yields to it, and the next paint brings it back.
1292
+ # `delete_field` scrubs this user's overlay values under the key too, which is right: the
1293
+ # tenant-wide cells went with `drop_field` above, and a private leftover would resurface under
1294
+ # whatever column later took the key.
1295
+ if fork is not None:
1296
+ try:
1297
+ table_store.make(_shared_key(), st=session.runtime).delete_field(session.uname, key)
1298
+ except Exception: # noqa: BLE001
1299
+ # The definition is already gone, so a failed fork scrub must not turn a delete that
1300
+ # succeeded into a 500. Worst case the column lingers for this one account until its
1301
+ # next write, which is recoverable; a 500 over completed work is not.
1302
+ pass
1303
  # ⭐⭐ THE GRANT DIES WITH THE COLUMN. `route_order_write` claims
1304
  # `shares.field_oid(SHARE_TOPIC, key)` on create, so skipping this would leave a grant record
1305
  # pointing at nothing β€” a ghost in every receiver's "Shared with me" that 404s on open, and
 
1312
  shares.drop_objects([("field", shares.field_oid(SHARE_TOPIC, key))], st=session.runtime)
1313
  except Exception: # noqa: BLE001
1314
  pass
1315
+ return {"ok": True, "field": key, "dropped": bool(dropped or fork is not None)}
1316
 
1317
 
1318
  @router.patch("/customers/route-order/{field_key}")
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -94,7 +94,8 @@ import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, VIEW_OPEN_EVENT,
94
  WORKSPACE_STALE_EVENT, signal }
95
  from "../apiContract";
96
  import type { ViewOpenDetail } from "../apiContract";
97
- import { addTableField, addTableRow, deleteRouteOrderField, deleteTableField, deleteTableRow, fetchLinkTargets,
 
98
  fetchRollupSources, patchRouteOrderField, patchTableField, enrichField } from "./apiBridge";
99
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
100
  import SelectFromFile from "./SelectFromFile";
@@ -1658,11 +1659,58 @@ function CustomerGridSurface({
1658
  setColumnVisible,
1659
  insertColumn,
1660
  } = useGridColumns(fields, config, updateConfig);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1661
  const revealRouteField = useCallback((key: string) => {
1662
- setColumnVisible(key, true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1663
  signal(WORKSPACE_STALE_EVENT);
1664
  signal(ROWS_STALE_EVENT);
1665
- }, [setColumnVisible]);
 
 
 
 
 
 
1666
  // W39-T22 β€” the transit link is an action, so it is appended to what Glide paints without
1667
  // joining the saved-view column contract. `onCellClicked` below is the one tap door.
1668
  const gridColumns = useMemo(
@@ -5495,11 +5543,15 @@ function CustomerGridSurface({
5495
  groupBy: config.groupBy === key ? null : config.groupBy,
5496
  colorBy: config.colorBy === key ? null : config.colorBy,
5497
  });
 
 
 
 
5498
  signal(WORKSPACE_STALE_EVENT);
5499
  signal(ROWS_STALE_EVENT);
5500
  });
5501
  },
5502
- [lockedKey, config, order, visible, updateConfig, setFields]
5503
  );
5504
 
5505
  /**
@@ -6427,8 +6479,19 @@ function CustomerGridSurface({
6427
  // column can only be deleted through the column menu, which offers it for `link`/`rollup`/
6428
  // `formula` alone (D-114's deliberate narrowness β€” every other kind holds real values). Booked
6429
  // as pending work rather than fixed by handing a type-blind delete to a panel.
 
 
 
 
 
 
 
 
 
6430
  const deletableKeys = new Set(
6431
- fields.filter((f) => f.custom && f.key !== lockedKey).map((f) => f.key)
 
 
6432
  );
6433
  const menuField = columnMenu ? fieldByKey.get(columnMenu.fieldKey) : undefined;
6434
  // Wave-5 item 3 β€” what the CURRENT VIEW does with the menu's field, so the conditional
@@ -6886,7 +6949,16 @@ function CustomerGridSurface({
6886
  orderedFields={orderedFields}
6887
  onFieldOrder={(keys) => updateConfig({ ...config, order: keys })}
6888
  deletableKeys={deletableKeys}
6889
- onDeleteField={deleteField}
 
 
 
 
 
 
 
 
 
6890
  /* ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL. This was `colorOn` plus a
6891
  boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
6892
  so on a table with two status columns the person got the first one and had no way to
 
94
  WORKSPACE_STALE_EVENT, signal }
95
  from "../apiContract";
96
  import type { ViewOpenDetail } from "../apiContract";
97
+ import { addTableField, addTableRow, clearTopicRowsCache, deleteRouteOrderField, deleteTableField,
98
+ deleteTableRow, fetchLinkTargets,
99
  fetchRollupSources, patchRouteOrderField, patchTableField, enrichField } from "./apiBridge";
100
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
101
  import SelectFromFile from "./SelectFromFile";
 
1659
  setColumnVisible,
1660
  insertColumn,
1661
  } = useGridColumns(fields, config, updateConfig);
1662
+ /**
1663
+ * ⭐⭐ OWNER, 2026-08-23 β€” A SAVED ROUTE'S COLUMN LANDS WHERE THE PERSON IS LOOKING.
1664
+ *
1665
+ * Owner: *"when I save a route, the route does not immediately show in the Field under Grid
1666
+ * view. I need it to immediately populate."*
1667
+ *
1668
+ * β›”β›” IT WAS `setColumnVisible`, AND THAT WRITES ONLY `config.visible`. The new key never
1669
+ * reached `config.order`, so `useGridColumns.reconcileOrder` fell through to its catch-all
1670
+ * (*"for every field not in `kept`, push"*) and APPENDED it β€” last, behind every Odoo column on
1671
+ * the customers grid. The column was created, visible and correct, and sat off the right edge
1672
+ * of a horizontal scroll nobody had a reason to make. Indistinguishable from hidden, which is
1673
+ * why the next thing the owner said was *"unhide the Route we just made"*.
1674
+ * `insertColumn` is the primitive that writes BOTH halves, and CustomerGrid's own note at the
1675
+ * "Insert left / right" branch already says so β€” this call site simply used the wrong one.
1676
+ *
1677
+ * β›” AND IT IS DEFERRED UNTIL `fields` ACTUALLY CARRIES THE KEY. `onRouteFieldCreated` fires
1678
+ * the instant the POST returns, which is BEFORE the workspace re-read this function asks for
1679
+ * has landed β€” so at that moment the field does not exist on the client, and `insertColumn`
1680
+ * would compute its slot from an `order` reconciled against a field list without it. The ref
1681
+ * holds the ask; the effect below spends it on the render where the field arrives.
1682
+ * [[mount-time-read-cannot-see-an-arrival]]
1683
+ *
1684
+ * ⚠ POSITION 1, NOT THE END. `insertColumn(key, lockedKey, "right")` puts it immediately after
1685
+ * the identity column, which is where a person who just planned a route is already looking.
1686
+ */
1687
+ const pendingRouteRevealRef = useRef<string | null>(null);
1688
  const revealRouteField = useCallback((key: string) => {
1689
+ if (fieldByKey.has(key)) {
1690
+ pendingRouteRevealRef.current = null;
1691
+ insertColumn(key, lockedKey, "right");
1692
+ } else {
1693
+ pendingRouteRevealRef.current = key;
1694
+ }
1695
+ // β›”β›” THE CACHE IS DROPPED BEFORE THE SIGNAL, AND THIS IS THE OTHER HALF OF "IT DOES NOT
1696
+ // POPULATE". `fetchTopicRows` holds one cached payload per topic, and `useCustomerData`'s own
1697
+ // note states the contract: *"Senders clear the rows cache first, so the refetch cannot be
1698
+ // served from the memo."* This sender did not β€” so `ROWS_STALE_EVENT` re-ran the load effect,
1699
+ // the load effect was answered out of the pre-save window, and the freshly revealed column
1700
+ // painted EMPTY over rows that had never seen a visit number. The workspace half was always
1701
+ // fine (`reread()` passes no `allowCached`); it is the ROWS that were a window behind.
1702
+ // ⚠ The same three lines `patchTopicRow` already runs when a rank SWAP moves another row,
1703
+ // for the same reason, which is why the drop belongs at the sender rather than in the hook.
1704
+ clearTopicRowsCache(topic.rowsPath);
1705
  signal(WORKSPACE_STALE_EVENT);
1706
  signal(ROWS_STALE_EVENT);
1707
+ }, [fieldByKey, insertColumn, lockedKey, topic.rowsPath]);
1708
+ useEffect(() => {
1709
+ const key = pendingRouteRevealRef.current;
1710
+ if (!key || !fieldByKey.has(key)) return;
1711
+ pendingRouteRevealRef.current = null;
1712
+ insertColumn(key, lockedKey, "right");
1713
+ }, [fieldByKey, insertColumn, lockedKey]);
1714
  // W39-T22 β€” the transit link is an action, so it is appended to what Glide paints without
1715
  // joining the saved-view column contract. `onCellClicked` below is the one tap door.
1716
  const gridColumns = useMemo(
 
5543
  groupBy: config.groupBy === key ? null : config.groupBy,
5544
  colorBy: config.colorBy === key ? null : config.colorBy,
5545
  });
5546
+ // β›” AND THE ROWS CACHE, for the reason `revealRouteField` states above: the values went
5547
+ // with the definition (`shared_overlay.drop_field` scrubs the cells), so a refetch served
5548
+ // from the pre-delete window would repaint the numbers of a column that no longer exists.
5549
+ clearTopicRowsCache(topic.rowsPath);
5550
  signal(WORKSPACE_STALE_EVENT);
5551
  signal(ROWS_STALE_EVENT);
5552
  });
5553
  },
5554
+ [lockedKey, config, order, visible, updateConfig, setFields, topic.rowsPath]
5555
  );
5556
 
5557
  /**
 
6479
  // column can only be deleted through the column menu, which offers it for `link`/`rollup`/
6480
  // `formula` alone (D-114's deliberate narrowness β€” every other kind holds real values). Booked
6481
  // as pending work rather than fixed by handing a type-blind delete to a panel.
6482
+ /**
6483
+ * ⭐ OWNER, 2026-08-23 β€” A ROUTE COLUMN IS DELETABLE FROM **HERE** TOO, not only from the
6484
+ * column menu. Owner: *"I can't even delete the Field route now?"* The column menu has carried
6485
+ * the route branch since owner item 3, but a route order carries no `custom` flag (see
6486
+ * `route_order_write`'s definition), so this set never held one and the Hide-fields panel β€”
6487
+ * the surface a person opens when a column is missing β€” showed no delete affordance at all.
6488
+ * ⚠ MATCHED ON THE DECLARED `kind`, never on the `route_` prefix, exactly as the column menu's
6489
+ * branch and the server door both are.
6490
+ */
6491
  const deletableKeys = new Set(
6492
+ fields
6493
+ .filter((f) => (f.custom || (f.shared && f.kind === "route_order")) && f.key !== lockedKey)
6494
+ .map((f) => f.key)
6495
  );
6496
  const menuField = columnMenu ? fieldByKey.get(columnMenu.fieldKey) : undefined;
6497
  // Wave-5 item 3 β€” what the CURRENT VIEW does with the menu's field, so the conditional
 
6949
  orderedFields={orderedFields}
6950
  onFieldOrder={(keys) => updateConfig({ ...config, order: keys })}
6951
  deletableKeys={deletableKeys}
6952
+ /* β›” TWO STRATA, TWO DOORS, dispatched on the same declared `kind` the column menu
6953
+ uses. `deleteField` emits a per-user `field_delete`, which `grid_events` would
6954
+ accept for a route key and apply to a bucket the shared definition never reads β€”
6955
+ the column would blink out for one paint and be back on the next fetch. That is the
6956
+ failure `deleteDefinitionField`'s own note records, one stratum over. */
6957
+ onDeleteField={(key) => {
6958
+ const target = fieldByKey.get(key);
6959
+ if (target && target.shared && target.kind === "route_order") deleteRouteField(key);
6960
+ else deleteField(key);
6961
+ }}
6962
  /* ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL. This was `colorOn` plus a
6963
  boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
6964
  so on a table with two status columns the person got the first one and had no way to
web/src/customer-grid/MapView.tsx CHANGED
@@ -486,6 +486,23 @@ export function MapView({
486
  >(null);
487
  /** Which saved column a save writes to. `""` = a new field, named in the prompt below. */
488
  const [saveTarget, setSaveTarget] = useState("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  /**
490
  * ⭐⭐ OWNER ITEM 5 (2026-08-23) β€” THE NAME IS ASKED FOR, AND ITS DEFAULT COMES FROM THE SERVER.
491
  *
@@ -1104,9 +1121,84 @@ export function MapView({
1104
  }
1105
  }, []);
1106
 
 
 
 
 
 
1107
  useEffect(() => {
1108
- if (routeOn && routeCols === null) void loadRouteCols();
1109
- }, [routeOn, routeCols, loadRouteCols]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
 
1111
  /**
1112
  * Write the visit numbers on screen into a tenant-wide column.
@@ -1333,14 +1425,37 @@ export function MapView({
1333
  * hiding it here would make a customer the user can name simply absent from the search, with no
1334
  * sentence anywhere saying why.
1335
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
  const addableRecords = useMemo(() => {
1337
  if (!allRows || allRows.length === 0) return [];
1338
- const out: { pid: number; title: string }[] = [];
 
1339
  for (const r of allRows) {
1340
  if (selectedPids.has(r.pid)) continue;
1341
- out.push({ pid: r.pid, title: String(r[field.key] ?? "").trim() || `#${r.pid}` });
 
 
1342
  }
1343
- return out.sort((a, b) => a.title.localeCompare(b.title));
 
 
1344
  }, [allRows, selectedPids, field.key]);
1345
 
1346
  const moveStop = useCallback(
@@ -1831,6 +1946,55 @@ export function MapView({
1831
  </span>
1832
  </div>
1833
  <div className="cg-map-route">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1834
  {/* ── the answer, or what is still missing ───────────────────────────────────── */}
1835
  {plan ? (
1836
  <span className="cg-map-route-sum">
@@ -2043,9 +2207,15 @@ export function MapView({
2043
  button and the Google links to the foot. It is a statement about what
2044
  the LIST ABOVE does not contain ("not listed above"), so it is the one
2045
  line in the old block that would become false the moment it travelled. */}
 
 
 
 
 
2046
  {(routeCols || []).some((f) => !f.mine) && (
2047
  <span className="cg-map-route-note">
2048
- Shared orders you can read but not re-solve are not listed above.
 
2049
  </span>
2050
  )}
2051
  </>
 
486
  >(null);
487
  /** Which saved column a save writes to. `""` = a new field, named in the prompt below. */
488
  const [saveTarget, setSaveTarget] = useState("");
489
+ /**
490
+ * ⭐⭐ OWNER, 2026-08-23 β€” WHICH SAVED ROUTE IS BEING **SHOWN**, which is not the same
491
+ * question as which column a save WRITES TO.
492
+ *
493
+ * Owner: *"when I click the Route navigation button. I should be able to choose any Route
494
+ * field I want to see the Routes."* Before this the only route control was `saveTarget`: it
495
+ * appeared behind `plan &&` (so you could not reach it until you had already solved something
496
+ * else), it listed `mine` columns only, and picking one changed nothing on the map because it
497
+ * named a WRITE destination. Reading a colleague's plan was not expressible at all.
498
+ *
499
+ * β›” TWO STATES BECAUSE THEY ARE TWO PERMISSIONS. Anyone the column was shared with may OPEN
500
+ * it; only its planner or an admin may re-solve it (`route_order_write`'s creator-or-admin
501
+ * wall, reported as `mine`). Collapsing them into one control would either offer a Save that
502
+ * earns a 403 or hide a route the person is allowed to read.
503
+ */
504
+ const [openedRoute, setOpenedRoute] = useState("");
505
+ const [openErr, setOpenErr] = useState<string | null>(null);
506
  /**
507
  * ⭐⭐ OWNER ITEM 5 (2026-08-23) β€” THE NAME IS ASKED FOR, AND ITS DEFAULT COMES FROM THE SERVER.
508
  *
 
1121
  }
1122
  }, []);
1123
 
1124
+ // ⭐ OWNER, 2026-08-23 β€” `routePanelOpen`, NOT `routeOn`. The saved-route picker is the first
1125
+ // control in the panel and it is how a person reaches somebody's plan without solving one of
1126
+ // their own, so the listing has to be in hand the moment the panel opens rather than after a
1127
+ // solve. Still a READ of our own store and still once per mount (`routeCols === null` is the
1128
+ // not-asked-yet state), so R3 is untouched: no routing request is spent by opening a panel.
1129
  useEffect(() => {
1130
+ if ((routePanelOpen || routeOn) && routeCols === null) void loadRouteCols();
1131
+ }, [routePanelOpen, routeOn, routeCols, loadRouteCols]);
1132
+
1133
+ /**
1134
+ * ⭐⭐ OWNER, 2026-08-23 β€” **OPEN A SAVED ROUTE AND SEE IT ON THE MAP.**
1135
+ *
1136
+ * β›” THE VISIT NUMBERS ARE ALREADY ON THE ROWS, so this needs no new door and no fetch.
1137
+ * `grid_assembly` merges `shared_cells` into `ws["overlays"]` and `rows_from_pool` iterates the
1138
+ * MERGED field list, so a route column's rank rides every row the reader may see, exactly like
1139
+ * any other cell. `GET /customers/route-order` deliberately serves only the DEFINITION (the
1140
+ * input fingerprint, the stop count, who planned it); asking it for ranks would be a second
1141
+ * answer to a question the grid payload already answers.
1142
+ *
1143
+ * β›” IT READS `allRows`, NOT `rows`, AND THAT IS THE WHOLE POINT OF OPENING A SAVED ROUTE. A
1144
+ * plan made on Tuesday was solved over Tuesday's cohort; today's view may filter half of it
1145
+ * out. `allRows` is the book AFTER the row wall and BEFORE the view's filters (see the prop's
1146
+ * own note), so opening a route shows the stops it was saved with and never a silent subset,
1147
+ * and the pids join `addedPids` so each one earns a pin the way a hand-added record does.
1148
+ *
1149
+ * ⚠ A RANK IS READ AS A NUMBER, NEVER AS A STRING. `shared_overlay` stores cells as strings
1150
+ * (`put_rows` sends `str(v)`), so sorting the raw values would order 10 before 2 and hand back
1151
+ * a plausible day in the wrong sequence.
1152
+ */
1153
+ const openSavedRoute = useCallback((key: string) => {
1154
+ setOpenedRoute(key);
1155
+ setOpenErr(null);
1156
+ if (!key) return;
1157
+ const book = allRows && allRows.length ? allRows : rows;
1158
+ const ranked: { pid: number; rank: number }[] = [];
1159
+ for (const r of book) {
1160
+ const raw = r[key];
1161
+ if (raw === null || raw === undefined || raw === "") continue;
1162
+ const rank = Number(raw);
1163
+ if (!Number.isFinite(rank)) continue;
1164
+ ranked.push({ pid: r.pid, rank });
1165
+ }
1166
+ // β›” REPORTED, NEVER SILENT (standing rule 1's second sentence). A route whose stops are all
1167
+ // outside this account's book is a real state β€” a colleague planned it over the other
1168
+ // business unit β€” and drawing nothing while the picker says a route is open would read as a
1169
+ // broken map rather than as a wall doing its job.
1170
+ if (ranked.length < 2) {
1171
+ setOpenErr(
1172
+ "That route has fewer than two stops in your book, so there is nothing to draw. The " +
1173
+ "records it was planned over may sit outside the rows you are permitted to see."
1174
+ );
1175
+ return;
1176
+ }
1177
+ ranked.sort((a, b) => a.rank - b.rank);
1178
+ const pids = ranked.map((x) => x.pid);
1179
+ setAddedPids((prev) => {
1180
+ const next = new Set(prev);
1181
+ for (const pid of pids) next.add(pid);
1182
+ return next;
1183
+ });
1184
+ onSelectPids(pids, "replace");
1185
+ setRouteOrder(pids);
1186
+ setRouteStartPid(null);
1187
+ setRouteOn(true);
1188
+ // ⚠ THE ROAD IS NOT RE-ASKED (R3). The stops and their order have changed, so the drawn
1189
+ // geometry is stale by `plan.key` and the panel already offers "Update the route"; spending a
1190
+ // provider request on a dropdown change is the thing R3 forbids.
1191
+ setSaveMsg(null);
1192
+ setSaveErr(null);
1193
+ // Only a route this account may re-solve becomes the SAVE target. Pointing Save at somebody
1194
+ // else's column would offer a button whose only possible answer is the 403 the server owes.
1195
+ const col = (routeCols || []).find((f) => f.key === key);
1196
+ setSaveTarget(col && col.mine ? key : "");
1197
+ // ⭐ AND THE COLUMN IS REVEALED IN THE GRID. The host callback is `revealRouteField`: opening
1198
+ // a route is an explicit ask to look at it, so the column it lives in stops being one of the
1199
+ // hidden ones. This is the same door a fresh save takes, for the same reason.
1200
+ onRouteFieldCreated?.(key);
1201
+ }, [allRows, rows, onSelectPids, routeCols, onRouteFieldCreated]);
1202
 
1203
  /**
1204
  * Write the visit numbers on screen into a tenant-wide column.
 
1425
  * hiding it here would make a customer the user can name simply absent from the search, with no
1426
  * sentence anywhere saying why.
1427
  */
1428
+ /**
1429
+ * β›”β›” OWNER, 2026-08-23 β€” NAMED RECORDS FIRST, UNNAMED LAST, AND THAT IS WHY THE LIST
1430
+ * LOOKED EMPTY OF ANYTHING USEFUL UNTIL YOU TYPED.
1431
+ *
1432
+ * Owner: *"when I click the 'search the database' under the routes, the dropdown shows ??? for
1433
+ * populating the route. Only when I search and type something does it show something."*
1434
+ *
1435
+ * This built EVERY title as `name || "#" + pid` and then sorted the whole mixed list with
1436
+ * `localeCompare`, which files punctuation ahead of letters. `PICKER_LIST_CAP` then takes the
1437
+ * first 40 β€” so with an unsearched query the panel showed forty rows of `#12345` and not one
1438
+ * customer name, and typing anything at all filtered those placeholder titles away and revealed
1439
+ * the real records underneath. The bug was never the search; it was the ORDER the cap sliced.
1440
+ *
1441
+ * ⚠ THE UNNAMED ARE NOT DROPPED, THEY ARE MOVED. A record with no value in the identity column
1442
+ * is still addable β€” it may be exactly the stop somebody is looking for β€” and hiding it would
1443
+ * make a record the user can see in the grid simply absent from the search with nothing saying
1444
+ * why. It sorts last and says what it is instead of wearing a bare id.
1445
+ */
1446
  const addableRecords = useMemo(() => {
1447
  if (!allRows || allRows.length === 0) return [];
1448
+ const named: { pid: number; title: string }[] = [];
1449
+ const unnamed: { pid: number; title: string }[] = [];
1450
  for (const r of allRows) {
1451
  if (selectedPids.has(r.pid)) continue;
1452
+ const title = String(r[field.key] ?? "").trim();
1453
+ if (title) named.push({ pid: r.pid, title });
1454
+ else unnamed.push({ pid: r.pid, title: `Unnamed record ${r.pid}` });
1455
  }
1456
+ named.sort((a, b) => a.title.localeCompare(b.title));
1457
+ unnamed.sort((a, b) => a.pid - b.pid);
1458
+ return [...named, ...unnamed];
1459
  }, [allRows, selectedPids, field.key]);
1460
 
1461
  const moveStop = useCallback(
 
1946
  </span>
1947
  </div>
1948
  <div className="cg-map-route">
1949
+ {/* ══ OWNER, 2026-08-23 β€” CHOOSE WHICH SAVED ROUTE TO LOOK AT ══════════════════
1950
+ Owner: *"when I click the Route navigation button. I should be able to choose any
1951
+ Route field I want to see the Routes."*
1952
+ β›” FIRST IN THE PANEL, AND OUTSIDE `plan &&`. Opening somebody's saved plan is
1953
+ how this surface starts for a rep driving a day that was planned for them; it
1954
+ cannot sit behind having already solved a route of your own.
1955
+ β›” EVERY route this account may see, `mine` or not β€” `GET /customers/route-order`
1956
+ has already applied the per-field wall, so what is listed here is exactly what
1957
+ the grid would show. Whether you may RE-SOLVE one is a different question, and
1958
+ it is answered by the Visit order field select further down. */}
1959
+ {(routeCols || []).length > 0 && (
1960
+ <>
1961
+ <label className="cg-map-route-opt cg-route-field">
1962
+ Saved route
1963
+ <select
1964
+ className="cg-route-select"
1965
+ value={openedRoute}
1966
+ aria-label="Which saved route to show on the map"
1967
+ onChange={(e) => openSavedRoute(e.target.value)}
1968
+ >
1969
+ <option value="">Choose a route to show</option>
1970
+ {(routeCols || []).map((f) => (
1971
+ <option key={f.key} value={f.key}>
1972
+ {f.label}
1973
+ </option>
1974
+ ))}
1975
+ </select>
1976
+ </label>
1977
+ {openErr && <span className="cg-cal-nodate">{openErr}</span>}
1978
+ {/* β›”β›” SAID OUT LOUD, BECAUSE THE FOOT BUTTON DOES SOMETHING ELSE THAN IT
1979
+ LOOKS LIKE HERE. Opening a route this account cannot re-solve leaves
1980
+ `saveTarget` empty, so the primary button at the foot reads "Save route"
1981
+ and mints a NEW column rather than touching the one on screen. That is a
1982
+ real and useful act β€” take a colleague's Monday, reorder it, keep your own
1983
+ copy β€” and it is the only one the server will allow (`route_order_write`
1984
+ is creator-or-admin for a re-solve). What would be wrong is doing it
1985
+ SILENTLY under a button whose position implies it acts on what is open.
1986
+ ⚠ Gated on a route being open AND not mine, so the sentence appears only
1987
+ in the situation it describes. */}
1988
+ {openedRoute &&
1989
+ (routeCols || []).some((f) => f.key === openedRoute && !f.mine) && (
1990
+ <span className="cg-map-route-note">
1991
+ You can open this route but not re-solve it, so Save route below will
1992
+ create a new route of your own rather than change this one.
1993
+ </span>
1994
+ )}
1995
+ </>
1996
+ )}
1997
+
1998
  {/* ── the answer, or what is still missing ───────────────────────────────────── */}
1999
  {plan ? (
2000
  <span className="cg-map-route-sum">
 
2207
  button and the Google links to the foot. It is a statement about what
2208
  the LIST ABOVE does not contain ("not listed above"), so it is the one
2209
  line in the old block that would become false the moment it travelled. */}
2210
+ {/* ⚠ THE OLD SENTENCE READ *"Shared orders you can read but not re-solve are
2211
+ not listed above"*, and the Saved route picker at the top of this panel made
2212
+ it false on the day it shipped: those routes ARE listed now, one control up.
2213
+ What is still true, and what a person needs told here, is why choosing one
2214
+ up there did not arm this Save. */}
2215
  {(routeCols || []).some((f) => !f.mine) && (
2216
  <span className="cg-map-route-note">
2217
+ A route somebody else planned can be opened under Saved route above. Only
2218
+ the person who planned it can re-solve it, so it is not offered here.
2219
  </span>
2220
  )}
2221
  </>