Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/routes_customers.py +106 -6
- web/src/customer-grid/CustomerGrid.tsx +78 -6
- web/src/customer-grid/MapView.tsx +176 -6
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v49",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "e6e361c",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v49",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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
|
| 1182 |
raise unknown
|
| 1183 |
# The wall FIRST, so a hidden column is indistinguishable from an absent one.
|
| 1184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1663 |
signal(WORKSPACE_STALE_EVENT);
|
| 1664 |
signal(ROWS_STALE_EVENT);
|
| 1665 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 1339 |
for (const r of allRows) {
|
| 1340 |
if (selectedPids.has(r.pid)) continue;
|
| 1341 |
-
|
|
|
|
|
|
|
| 1342 |
}
|
| 1343 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
</>
|