toga-ai 1.0.162 → 1.0.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,3 +9,4 @@
9
9
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem |
10
10
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
11
11
  | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php |
12
+ | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php |
@@ -0,0 +1,173 @@
1
+ ---
2
+ title: App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: [jcardinal]
11
+ files:
12
+ - library/app/api/toga2.php
13
+ - worker/crons/toga2/aig/sync_togasupply_aig.php
14
+ - worker/crons/toga2/wje/sync_togasupply_wje.php
15
+ related:
16
+ - netsuite-suiteql-api-reference.md
17
+ - netsuite-suiteql-rest-shim.md
18
+ - ../../worker/features/netsuite-togasupply-per-client-sync.md
19
+ - ../architecture.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the
25
+ TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross-framework sync routines
26
+ that move data between the legacy 1.0 stack (TOGa, TOGaDesk) and 2.0 (TOGa Supply, TOGa Desk
27
+ 2.0). It plays two roles:
28
+
29
+ 1. **Transport** — `send()`/`authenticate()`/`sendUsingAccessToken()` plus an options DSL
30
+ (`fields`/`where`/`join`/`sort`) that lets 1.0 worker crons call the 2.0 REST API. This is
31
+ the same transport the NetSuite→TOGa Supply importer uses (see the per-client-sync doc).
32
+ 2. **Bridge sync routines** — `syncWithToga()` (2.0 → 1.0 TOGa) and `syncWithTogadesk()` (2.0 ⇄
33
+ 1.0 TOGaDesk), invoked by thin per-client crons under `worker/crons/toga2/<client>/`.
34
+
35
+ The `*FromNetsuite()` methods (`syncSalesOrderFromNetsuite`, etc.) also live in this class but
36
+ belong to the NetSuite importer — documented in the per-client-sync doc, not here.
37
+
38
+ ## Key files / entry points
39
+
40
+ - **`library/app/api/toga2.php`**
41
+ - **Transport:** `authenticate($uuidClient,$uuidApi,$apiSecret,$endpoint=null)`,
42
+ `send($uuidClient,$uuidApi,$apiSecret,$method,$route,$payload,$options=[],$throwOnError=true,$endpointOverride=null)`,
43
+ `sendUsingAccessToken(...)`, `authenticateUser(...)`.
44
+ - **Options DSL builders:** `assembleOptions`, `assembleOptionsFields`, `assembleOptionsWhere`,
45
+ `assembleOptionsJoin`, `assembleOptionsSort`.
46
+ - **Bridge 2.0→1.0:** `syncWithToga(...)` → `syncContactToToga1Customer`,
47
+ `syncEntitlementToToga1ServiceRequest`.
48
+ - **Bridge 2.0⇄1.0 TOGaDesk:** `syncWithTogadesk(...)` + its many sub-syncs
49
+ (`syncToga2TicketIntoTogadesk1{RepairOrder,Ticket}`, `syncTogaDesk1{RepairOrder,Ticket}IntoToga2Ticket`,
50
+ `syncToga2ContactIntoTogadesk1People`, `syncToga2ItemUnitIntoTogadesk1Asset`,
51
+ `syncToga2PredefinedReplyToTogadesk1TicketsPr`, `syncToga2TicketTeamsIntoTogadesk1Groups`,
52
+ `syncToga2TicketCategoryIntoTogadesk1CategorySubcategoryItem`, `sendTicketNotifications`).
53
+ - **Per-client invokers** (worker, one folder per client, distinct from the `netsuite/` supply
54
+ wrappers): e.g. `worker/crons/toga2/aig/sync_togasupply_aig.php` (calls `syncWithToga` +
55
+ `syncWithTogadesk` for tickets), `worker/crons/toga2/wje/sync_togasupply_wje.php`
56
+ (TOGaDesk-only, all sub-syncs on). These run on tight schedules (AIG every 10 min, WJE every
57
+ 2 min) under the standard worker boilerplate.
58
+
59
+ ## How it works
60
+
61
+ ### Transport (`authenticate` + `send`)
62
+
63
+ - **Endpoint** comes from `App_Registry::get('config')['api']['_']` unless an override is passed
64
+ as the last arg (the bridge passes `self::$toga2ApiEndpoint`).
65
+ - **Auth** posts `{client,api,secret}` to `/auth/api` and caches the returned `{access,refresh}`
66
+ in a static `self::$_tokens[$uuidApi]`. An expired access token is refreshed via `/auth/refresh`
67
+ with the refresh token. Both auth paths retry up to **10×** (1s sleep) before throwing.
68
+ - **`send()`** authenticates, calls `sendUsingAccessToken()`, and on an **`EN-4`** message
69
+ (expired token) clears the cached access token and retries once. Connect/read timeouts are
70
+ generous (300s/900s) because list endpoints can be slow. A `usleep(200000)` throttle precedes
71
+ every call to be gentle on the API. Non-success responses throw unless `$throwOnError=false`.
72
+ - Returns the decoded response object; callers read `->data->{resource}`, `->meta->nextPage`,
73
+ `->isSuccess`, `->status`, `->messages[].code`.
74
+
75
+ ### Options DSL (how 1.0 expresses 2.0 queries)
76
+
77
+ `$options` is an assoc array assembled into the 2.0 query string:
78
+ - `fields` — nested arrays become dotted paths (`['primaryContactAddress'=>['address'=>['line1']]]`
79
+ → `primaryContactAddress.address.line1`); spaces become `+`.
80
+ - `where` — `['and'=>[['_updated'=>['>'=>$dt]]]]` compiles to `(_updated:gt:<urlenc>)`; operators
81
+ map (`>`→`gt`, `=`→`eq`, `in`/`notin` join with `:`); nestable `and`/`or` groups with parens.
82
+ - `join`/`ojoin` — `[['LocationTypes'=>['LocationTypes.id'=>'Locations.locationTypeId']]]`.
83
+ - `page`/`recordsPerPage`/`depth`/`calcDepth`/`key` pass through. Pagination is the universal
84
+ `for ($page=1,$nextPage=0; !is_null($nextPage); ++$page)` loop reading `meta->nextPage`.
85
+
86
+ ### `syncWithToga()` — 2.0 → 1.0 (TOGa)
87
+
88
+ Signature: `(int $togaClientId, string $clientUuid, string $apiKey, string $apiSecret,
89
+ bool $enabled2to1, bool $enabled1to2)`. When `enabled2to1`:
90
+ - Pages 2.0 **`/contacts`** (for AIG, only those with `c_togaCustomerId IS NULL`) and upserts each
91
+ into the 1.0 client DB `Customers`/`Addresses` via `syncContactToToga1Customer`, then **writes
92
+ the new 1.0 id back** to the 2.0 contact's `c_togaCustomerId` (PUT `/contacts/{uuid}`).
93
+ - Pages 2.0 **`/entitlements`** without a `c_togaServiceRequestId` and inserts 1.0
94
+ `ServiceRequests` (+ a `Contacts` row if needed) via `syncEntitlementToToga1ServiceRequest`.
95
+ - The 1→2 direction (`enabled1to2`) is a stub (not yet implemented).
96
+ - DB target is selected with `App_Model_Client::changeDbLinkToClientDatabase(getClientDatabaseNames()[$togaClientId])`.
97
+
98
+ ### `syncWithTogadesk()` — 2.0 ⇄ 1.0 (TOGaDesk)
99
+
100
+ A large bi-directional sync gated by **two checkpoint parameters** stored in 2.0 via
101
+ `/parameters`: `TOGADESK_LAST_TICKET_INTEGRATION_DATETIME` (newest 2.0 ticket pulled into 1.0)
102
+ and `TOGA_LAST_TICKET_INTEGRATION_DATETIME` (newest 1.0 ticket pushed into 2.0). Each direction
103
+ filters on records changed after its checkpoint, then advances it to the newest record seen.
104
+
105
+ Signature takes `$integrationType` (`TICKETS` or `REPAIR_ORDERS`) plus **seven independent
106
+ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
107
+ - **2.0 → TOGaDesk** (`$isEnabledToga2ToTogadeskIntegration`): pages 2.0 `/tickets` and
108
+ `/ticket-notes` changed since the checkpoint; routes each to
109
+ `syncToga2TicketIntoTogadesk1{RepairOrder|Ticket}` by `$integrationType`. Per-ticket exceptions
110
+ are caught and sent to Sentry so one bad ticket doesn't abort the batch.
111
+ - **TOGaDesk → 2.0** (`$isEnabledTogadeskToToga2Integration`): queries the 1.0 `db_togadesk` DB
112
+ directly (SQL over `repair_orders`/`repair_order_history` or `tickets`/`tickets_replies`/`comments`,
113
+ using `GREATEST(...)` of reply/comment timestamps) for the client's department(s), then calls
114
+ `syncTogaDesk1{RepairOrder|Ticket}IntoToga2Ticket($id)`.
115
+ - Five further one-way 2.0 → TOGaDesk sub-syncs, each its own flag: Contacts→People,
116
+ Items/Units→Assets, Predefined-Replies→TicketsPr, Ticket-Teams→Groups,
117
+ Ticket-Categories→Category/Subcategory/Items.
118
+ - Finishes by PUT-ing both checkpoint parameters back to 2.0.
119
+
120
+ ## Data model
121
+
122
+ - **2.0 side** is reached only through the API (never direct SQL): resources like `/contacts`,
123
+ `/entitlements`, `/tickets`, `/ticket-notes`, `/units`, `/parameters`, and the TOGa Supply
124
+ resources used by the importer.
125
+ - **1.0 side** is written with direct SQL through `App_Database` against per-client link strings
126
+ (`db_toga`, `db_togadesk`), after `changeDbLinkToClientDatabase()`. Tables touched include
127
+ `Customers`, `Addresses`, `Contacts`, `ServiceRequests` (TOGa) and `repair_orders`,
128
+ `repair_order_history`, `tickets`, `tickets_replies`, `comments` (TOGaDesk).
129
+ - **Checkpoints:** supply importer uses per-record-type `NETSUITE_*` keys (see per-client-sync
130
+ doc); the TOGaDesk bridge uses the two `*_LAST_TICKET_INTEGRATION_DATETIME` keys above. Both
131
+ live in the 2.0 client `Parameters` table and are read/written via `/parameters`.
132
+
133
+ ## Client variations
134
+
135
+ - **Per-client credentials** (client/api/secret UUIDs) and TOGaDesk client/department IDs are
136
+ passed in by each cron — defined either as `App_Api_Toga2::{CLIENT_UUID_*,API_UUID_*,API_SECRET_*}`
137
+ class constants or as literals in the cron.
138
+ - Behavior is tuned per client by which enable flags are passed: AIG runs `syncWithToga` +
139
+ TOGaDesk **tickets inbound only**; WJE runs TOGaDesk with **all** sub-syncs on. The
140
+ `syncWithToga` contact query has an explicit `$togaClientId === 15` (AIG) special case.
141
+
142
+ ## Gotchas / known issues
143
+
144
+ - **🔐 Hardcoded credentials.** Every client's `CLIENT_UUID_*`, `API_UUID_*`, and `API_SECRET_*`
145
+ (plus the "True Workers" master keys) are committed as plaintext class constants at the top of
146
+ `toga2.php`. Treat them as **compromised-if-leaked**; do not reproduce the values in the KB,
147
+ tickets, or logs. Long-term they should move to config/SSM. (Observed 2026-06-23.)
148
+ - **Two unrelated `sync_togasupply_<client>.php` families.** Files under
149
+ `worker/crons/toga2/netsuite/` are the **NetSuite supply importer** (`require` the common
150
+ engine). Files under `worker/crons/toga2/<client>/` (e.g. `aig/`, `wje/`) are this **bridge**
151
+ (`syncWithToga`/`syncWithTogadesk`). Same filename, different integration, different parameter
152
+ keys — they coexist harmlessly. Confirm which folder before editing.
153
+ - **`syncWithToga` and the bridge build SQL by hand** with `App_Database::sqlEscape()` rather than
154
+ the `App_Model` layer; follow the surrounding escaping discipline when modifying.
155
+ - **Per-record error isolation** in the TOGaDesk sync is via `try/catch` → `\Sentry\captureException`;
156
+ a thrown exception elsewhere (transport, checkpoint read) still aborts the whole run.
157
+ - **PHP 7.2** target (prod worker/library) — no arrow functions, typed properties, `??=`, `match`.
158
+ Lint with `C:\xampp7\php\php.exe -l`.
159
+
160
+ ## Change history
161
+
162
+ - 2026-06-23 — Initial documentation of the `App_Api_Toga2` transport (auth/token caching, options
163
+ DSL) and the 1.0↔2.0 bridge routines `syncWithToga` (Contacts/Entitlements → Customers/ServiceRequests)
164
+ and `syncWithTogadesk` (bi-directional ticket/repair-order/people/asset sync with dual checkpoint
165
+ parameters). Flagged the committed per-client API credentials. (jcardinal)
166
+
167
+ ## Related docs
168
+
169
+ - [NetSuite → TOGa Supply Per-Client Sync](../../worker/features/netsuite-togasupply-per-client-sync.md)
170
+ — the importer that uses this class's transport and `*FromNetsuite()` methods.
171
+ - [NetSuite SuiteQL/REST API Reference](netsuite-suiteql-api-reference.md) and
172
+ [SuiteQL/REST Shim](netsuite-suiteql-rest-shim.md) — the NetSuite side (`App_Api_Netsuite_Rest`).
173
+ - [Library architecture](../architecture.md) — `App_Api` pattern, autoloader, `App_Database` layer.
@@ -16,6 +16,7 @@ files:
16
16
  related:
17
17
  - ../architecture.md
18
18
  - forecast2-netsuite-reconciliation.md
19
+ - ../../library/features/toga2-api-client-and-bridge.md
19
20
  ---
20
21
 
21
22
  ## Summary
@@ -161,3 +162,6 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
161
162
  - [Worker architecture](../architecture.md) — cron dispatch, schedule-as-source-of-truth.
162
163
  - [Forecast2 NetSuite reconciliation](forecast2-netsuite-reconciliation.md) — sibling NetSuite
163
164
  sync surface (different target DB).
165
+ - [App_Api_Toga2 — API Client & 1.0↔2.0 Bridge](../../library/features/toga2-api-client-and-bridge.md)
166
+ — the transport (`send`/`authenticate`/options DSL) and `*FromNetsuite()` methods this engine calls,
167
+ plus the separate `syncWithToga`/`syncWithTogadesk` bridge that shares the `sync_togasupply_*` filename.
@@ -0,0 +1,9 @@
1
+ # toga25-supply (TOGa 2.5 Supply) — 2.0 knowledge
2
+
3
+ | Doc | Summary | Files |
4
+ |-----|---------|-------|
5
+ | [TOGa 2.5 Supply — Architecture](architecture.md) | `toga25-supply` ("TOGa 2.5 Supply") is the **React/TypeScript frontend** for the 2.0 Supply application — an iteration and improvement of `toga2-supply`. | toga25-supply/src/main.tsx, toga25-supply/src/App.tsx, toga25-supply/src/routes.tsx, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/layout/, toga25-supply/src/pages/, toga25-supply/src/hooks/useTableCellInteractions.ts |
6
+ | [Client-Configurable Fields (useClientFields / fieldsConfig)](features/client-configurable-fields.md) | The mechanism for config that **varies by client** (or client × role) — field overrides, filter buttons, group-by options, column pickers, layout toggles — with | toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/README.md |
7
+ | [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
8
+ | [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
9
+ | [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/, toga25-supply/src/hooks/useTableCellInteractions.ts |
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: TOGa 2.5 Supply — Architecture
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: architecture
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/main.tsx
13
+ - toga25-supply/src/App.tsx
14
+ - toga25-supply/src/routes.tsx
15
+ - toga25-supply/src/fieldsConfig/index.ts
16
+ - toga25-supply/src/fieldsConfig/useClientFields.ts
17
+ - toga25-supply/src/layout/
18
+ - toga25-supply/src/pages/
19
+ - toga25-supply/src/hooks/useTableCellInteractions.ts
20
+ related:
21
+ - features/client-configurable-fields.md
22
+ - features/meta-driven-table-data.md
23
+ - features/column-visibility.md
24
+ - features/record-modals-and-nested-tables.md
25
+ ---
26
+
27
+ ## Summary
28
+
29
+ `toga25-supply` ("TOGa 2.5 Supply") is the **React/TypeScript frontend** for the 2.0 Supply
30
+ application — an iteration and improvement of `toga2-supply`. It is a Vite + React 18 SPA
31
+ (react-router-dom 7, TanStack Query 5, react-hook-form 7) that talks to the 2.0 backend API
32
+ (`_underscore`). It does **not** depend on `toga2-supply`; its components, table templates, and
33
+ data hooks come from the shared in-house library **`@agilant/toga-blox`** (repo `toga-blox-npm`,
34
+ currently consumed at `1.0.318-beta.37`).
35
+
36
+ The app is **multi-client and meta-driven by design**. The same build serves many clients
37
+ (Compass USA/Canada, NYC Health & Hospitals, Quad, Prudential, GroWrk, and others — there are
38
+ per-client build scripts such as `nychh`, `compass`, `compasscanada`, `growrk`, `prudential`,
39
+ `spglobal`, `bankofamerica`, `wmchealth`, `quad`). Almost nothing is hardcoded per client:
40
+ table columns/labels come from server **table meta**, and client/role-specific UI config
41
+ (filter buttons, group-by options, column pickers, layout toggles) is resolved at runtime
42
+ through `src/fieldsConfig` + `useClientFields()`. Pages are thin; the real logic lives in
43
+ per-page **view models** (`use*ViewModel` hooks) and reusable **layout** components that wrap
44
+ toga-blox table/modal templates.
45
+
46
+ **Critical rules:**
47
+ - **Don't hardcode per-client behavior in components or view models** — resolve it through
48
+ `useClientFields()` / `src/fieldsConfig`, keyed by `clientSlug` (from `useHostnameStore`) ×
49
+ `role`. Every config must have a `DEFAULT` fallback or a client/role with no entry won't render.
50
+ - **A table's pagination-vs-infinite-scroll mode is decided solely by `isPaginationEnabled` from
51
+ the table meta**, consumed via the single `useTableData` hook — never pick a data hook or
52
+ hardcode the mode per page.
53
+ - **`@agilant/toga-blox` is symlinked from the local `toga-blox-npm` repo and the app consumes its
54
+ `dist/`** — after editing toga-blox source you MUST rebuild (`cd toga-blox-npm && npm run build`)
55
+ or the change won't reach runtime. Patching only `src` is a silent no-op.
56
+ - **Source files in this repo use TAB indentation** — match exactly or string-replace edits fail.
57
+
58
+ ## Tech stack & entry points
59
+
60
+ - **Build/dev:** Vite. `src/main.tsx` → `src/App.tsx`; routes in `src/routes.tsx`. Per-client
61
+ dev/build scripts in `package.json` (`dev`, `build`, plus `nychh`, `compass`, `growrk`, etc.).
62
+ - **Data:** TanStack Query 5 (`@tanstack/react-query`). The canonical table query key prefix is
63
+ `["table-data", slug, …]`; a blanket `invalidateQueries({ queryKey: ["table-data"] })` refetches
64
+ any table after a mutation.
65
+ - **Forms:** react-hook-form 7 (`FormProvider` + `BaseInput`), used by the create/edit record modals.
66
+ - **Routing:** react-router-dom 7. URL search params are the primary state store for tables
67
+ (sorting, filters, pagination, active-row uuid, column visibility, nested-table swap keys).
68
+ - **UI library:** `@agilant/toga-blox` provides `PrimaryTable*Layout`, `TableRecordModal`,
69
+ `BaseInput`, `BaseButton`, `DetailSection`, the `useTableData` hook, FontAwesome helpers, etc.
70
+
71
+ ## Directory layout (`src/`)
72
+
73
+ - `pages/<Entity>/` — page component (thin) + `viewModel/` (the `use*PageViewModel` hook) +
74
+ per-page `FIELDS/<CLIENT>/*.json` client config + `hooks/` + `dummyData/` (column-width JSON).
75
+ - `layout/<Thing>Layout/` — reusable table/modal layout wrappers around toga-blox templates,
76
+ each with its own `use*ViewModel`. Key ones: `SalesOrdersTableLayout`,
77
+ `SalesOrderRecordModalLayout`, `SalesOrderItemsTableLayout`, `ItemFulfillmentModal`,
78
+ `ItemRecordModalLayout`, `GenericNestedTables`, `RecordApprovalModalLayout`.
79
+ - `fieldsConfig/` — `index.ts` (the `ClientFields` bundle + `FIELDS[clientSlug][role]` map) and
80
+ `useClientFields.ts` (resolves active client/role, falls back to `DEFAULT_CLIENT_FIELDS`).
81
+ - `globalFieldsConfig/`, `hooks/`, `stores/` (e.g. `useHostnameStore`), `contexts/`, `utils/`,
82
+ `types/`, `api/`, `styles/`, `themeConfig.json`.
83
+
84
+ ## Architectural pillars (see feature docs)
85
+
86
+ 1. **Client/role-driven config** — runtime UI config without rebuilds. See
87
+ [client-configurable-fields](features/client-configurable-fields.md).
88
+ 2. **Meta-driven table data** — one `useTableData` hook; meta's `isPaginationEnabled` picks the
89
+ fetch strategy. See [meta-driven-table-data](features/meta-driven-table-data.md).
90
+ 3. **URL-driven column visibility** — shareable show/hide-columns, client-gated button. See
91
+ [column-visibility](features/column-visibility.md).
92
+ 4. **Layered modals over tables** — record modals, nested client/server tables, cell-click
93
+ modals, and the `GenericNestedTables` config-driven renderer. See
94
+ [record-modals-and-nested-tables](features/record-modals-and-nested-tables.md).
95
+
96
+ ## toga-blox relationship
97
+
98
+ `@agilant/toga-blox` is the shared frontend component/template/utility library (repo
99
+ `toga-blox-npm`). It is **not published to npm for this app's purposes** — the app is currently
100
+ its primary consumer and resolves it via a symlinked `node_modules/@agilant/toga-blox` →
101
+ `toga-blox-npm`. Because the app imports the built `dist/` (JS + `.d.ts`), any change to toga-blox
102
+ **source** requires a rebuild before it is visible at runtime; in urgent cases the `dist/` files
103
+ (and their `.d.ts`) have been patched directly. The `/update-blox` skill covers version bumps.
104
+
105
+ ## Conventions & gotchas
106
+
107
+ - **Tabs, not spaces** for indentation in this repo's source.
108
+ - **`BaseButton` is a default import:** `import BaseButton from "@agilant/toga-blox/dist/components/BaseButton/BaseButton.js"`.
109
+ - **Per-consumer React Query cache scope:** when two components fetch the same record with
110
+ different field configs, share a `scope` discriminator in the query key so they don't clobber
111
+ each other's cached data (e.g. record modal vs. approval modal — `scope="record-modal"` vs
112
+ `scope="approval-modal"`). Including `additionalData` in the query key is mandatory for
113
+ WHERE-filtered modal tables, or React Query serves a stale unfiltered first render.
114
+ - **Typecheck with `npx tsc --noEmit` / `npx tsc -b`** after changes; adding a required key to
115
+ `ClientFields` surfaces every bundle missing it.
@@ -0,0 +1,88 @@
1
+ ---
2
+ title: Client-Configurable Fields (useClientFields / fieldsConfig)
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/fieldsConfig/index.ts
13
+ - toga25-supply/src/fieldsConfig/useClientFields.ts
14
+ - toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/
15
+ - toga25-supply/src/pages/Inventory/viewModel/FIELDS/
16
+ - toga25-supply/src/pages/Inventory/README.md
17
+ related:
18
+ - ../architecture.md
19
+ - column-visibility.md
20
+ ---
21
+
22
+ ## What it is
23
+
24
+ The mechanism for config that **varies by client** (or client × role) — field overrides,
25
+ filter buttons, group-by options, column pickers, layout toggles — without hardcoding it in
26
+ the view model or rebuilding per client. All such config resolves through `src/fieldsConfig`
27
+ and the `useClientFields()` hook.
28
+
29
+ ## How it works
30
+
31
+ 1. **Per-client JSON lives next to the consuming page**, under `FIELDS/<CLIENT>/`
32
+ (e.g. `src/pages/SalesOrders/viewModel/FIELDS/COMPASS/salesOrdersPageFields.json`). There is
33
+ **always a `DEFAULT/`** folder every client falls back to. `<CLIENT>` must match the hostname
34
+ `clientSlug` (uppercase — `COMPASS`, `NYCHH`, …).
35
+ 2. **`src/fieldsConfig/index.ts`** imports those JSON files and wires them into the
36
+ `ClientFields` bundle — a normalized `FIELDS[clientSlug][role]` map merged over `DEFAULT`.
37
+ 3. **`useClientFields()`** resolves the active `clientSlug` (from `useHostnameStore`) + `role`
38
+ (from `resolveRole`), looks up the bundle, falls back to `DEFAULT_CLIENT_FIELDS`, and caches
39
+ the result in React Query. It **always returns a defined bundle** (DEFAULT while loading/missing),
40
+ so reading `clientFields.yourKey` is safe without optional chaining — but guard array access.
41
+ 4. **The view model reads its slice:**
42
+ ```ts
43
+ const { fields: clientFields } = useClientFields();
44
+ const tenantFields = clientFields.salesOrdersPageFields; // your key
45
+ ```
46
+
47
+ ### By client × role vs. by client only
48
+
49
+ - **Client × role** (e.g. `salesOrdersPageFields` — ADMIN vs MANAGER see different filter buttons):
50
+ the JSON is keyed by role at the top level (`{ "ADMIN": {...}, "MANAGER": {...} }`); use a
51
+ `pick*`/`merge*` helper to merge `DEFAULT[role]` with the client's `[role]`.
52
+ - **Client only** (e.g. `inventoryGroupings` — every role sees the same group-by options): the JSON
53
+ is flat, and you **inject** it across every role bundle for that client rather than hand-writing
54
+ it into each. Author the map literal as `FIELDS_BASE` (typed `Omit<ClientFields, "yourKey">`),
55
+ then build `FIELDS` by mapping each `[clientSlug][role]` entry and grafting
56
+ `yourKey: BY_CLIENT[clientSlug] ?? DEFAULT_*`. Avoids editing all ~13 role bundles.
57
+ `DEFAULT_CLIENT_FIELDS` must also carry the new key (ultimate fallback).
58
+
59
+ ### Hydration (non-serializable bits)
60
+
61
+ Keep JSON **serializable** — strings, enums, arrays. No JSX, no functions, no Tailwind classes.
62
+ Anything non-serializable is referenced by an **enum key** and resolved in the view model via a
63
+ registry that maps enum → real value (e.g. `MODAL_RENDERERS: Record<ModalKey, renderModal>`).
64
+ This is the one place app-owned JSX is grafted back onto client-authored config. Clients never
65
+ author JSX.
66
+
67
+ ## Adding a new client-configurable field
68
+
69
+ 1. Author the serializable JSON under `FIELDS/DEFAULT/<thing>.json` + a folder per overriding client.
70
+ 2. Declare the types next to the JSON (`FIELDS/index.ts`) and export the shape.
71
+ 3. Wire into `fieldsConfig/index.ts`: add the key to `ClientFields`, import each client's JSON,
72
+ add the value to **every** bundle + `DEFAULT_CLIENT_FIELDS` (or use the injection pattern).
73
+ 4. Consume in the view model via `useClientFields()`.
74
+ 5. Hydrate enum→value in the view model if the config references components/render callbacks.
75
+ 6. `npx tsc -b` — a new required `ClientFields` key surfaces every bundle missing it.
76
+
77
+ ## Gotchas
78
+
79
+ - **Always provide `DEFAULT`.** Resolution order: `FIELDS[client]?.[role]` → `FIELDS[client]?.DEFAULT`
80
+ → `DEFAULT_CLIENT_FIELDS`. A client/role with no entry must still render.
81
+ - **New client *file* = no code change** (just import/wiring). **New render style/modal type
82
+ (new enum value) = code change** in the hydration registry — that's the intended boundary
83
+ between client config and app rendering.
84
+ - Worked examples: `salesOrdersPageFields` (client × role) and `inventoryGroupings` (client-only +
85
+ hydration; full write-up in `src/pages/Inventory/README.md`).
86
+
87
+ ## Change history
88
+ - 2026-06-23 — Documented from the `add-client-fields` skill during initial knowledge seed. (apeterson)
@@ -0,0 +1,98 @@
1
+ ---
2
+ title: Column Visibility (URL-driven show/hide columns)
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/components/ColumnVisibilityModal/
13
+ - toga25-supply/src/pages/SalesOrders/SalesOrders.tsx
14
+ - toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
15
+ - toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx
16
+ related:
17
+ - ../architecture.md
18
+ - client-configurable-fields.md
19
+ - meta-driven-table-data.md
20
+ ---
21
+
22
+ ## What it is
23
+
24
+ A "Columns" header button that opens a modal listing every column from the table meta, lets the
25
+ user show/hide columns, adjusts the table live, and persists the selection in the URL as a
26
+ shareable link. The button's presence/label/icon — and the modal's entire copy — are driven per
27
+ client × role via the FIELDS config. Built for pages using `PrimaryTableServerLayout` (the Sales
28
+ Orders pattern). Reference: `ColumnVisibilityModal` + Sales Orders page/view model/data hook.
29
+
30
+ ## How the table renders columns (key facts)
31
+
32
+ - The table meta (`TableViewMeta` / `DataTableMetaProp`) carries `fields: TableViewField[]`; each
33
+ field has a unique `slug` and an `isVisible`. `buildTanstackColumns` (toga-blox) **filters by
34
+ `isVisible`** before building columns, so the tanstack column `id` is the field `slug`. Hiding =
35
+ `isVisible: false` or removing the field — identical column set.
36
+ - `getDataTableData` builds the API field list **from `meta.fields`**. So the meta used for
37
+ *fetching* must keep all fields; only the meta passed to the *table* should be visibility-adjusted.
38
+ **Keep two metas separate: fetch = full, display = adjusted.**
39
+
40
+ ## URL-state semantics
41
+
42
+ Single param `cols` = comma-separated **visible** column slugs.
43
+ - **Absent** → fall back to each field's default `isVisible`.
44
+ - **Present (even empty)** → only the listed slugs are visible. Distinguish absent from empty with
45
+ `searchParams.has("cols")`, NOT truthiness (so "hide all" works):
46
+
47
+ ```ts
48
+ const visibleSlugSet = searchParams.has("cols")
49
+ ? new Set((searchParams.get("cols") ?? "").split(",").filter(Boolean))
50
+ : null;
51
+ const isFieldVisible = (f) => visibleSlugSet ? visibleSlugSet.has(f.slug) : f.isVisible;
52
+ ```
53
+
54
+ ## Steps
55
+
56
+ 1. **Reuse the modal.** `ColumnVisibilityModal` is generic and holds **zero hardcoded copy** —
57
+ props `{ isOpen, columns: { slug, label, visible }[], labels, icon, onApply(visibleSlugs),
58
+ onReset?, onClose }`. `labels` = `{ title, showAll, hideAll, reset, cancel, apply, close }`;
59
+ `icon` = `{ name, variant }`. It keeps pending state internally, commits only on Apply.
60
+ 2. **View model — derive visibility from URL.** Add `visibleSlugSet`/`isFieldVisible`,
61
+ `columnOptions` (`fields.map(f => ({ slug, label: f.label ?? f.slug, visible: isFieldVisible(f) }))`),
62
+ `displayTableMeta` (`{ ...meta, fields: fields.map(f => ({ ...f, isVisible: isFieldVisible(f) })) }` —
63
+ return THIS as the table meta; data hook keeps full meta), `columnVisibilityKey` (visible slugs
64
+ joined; remount key), and `applyColumnVisibility(slugs)`/`resetColumnVisibility()`.
65
+ 3. **Data hook — exclude `cols` from the query key.** Where the slug key is stripped from
66
+ `tableSearchParams`, also `filtered.delete("cols")` — else every toggle refetches.
67
+ 4. **Page — button + modal + remount key.** Local `isColumnsModalOpen` state; render the button
68
+ from client config; pass `columnsButton.modal.labels`/`.icon` to the modal; put
69
+ `key={columnVisibilityKey}` on the table layout (required — see gotcha).
70
+ 5. **Make button + all modal copy client-configurable** (mirrors `filterButtons`; see
71
+ [client-configurable-fields](client-configurable-fields.md)). Add a `columnsButton` block to each
72
+ role in `FIELDS/<CLIENT>/<page>Fields.json` (and to `DEFAULT` first). Resolve it off `tenantFields`
73
+ in the view model into `{ icon, labels }` with **per-key fallbacks living in the resolution layer**,
74
+ never in the component.
75
+ 6. **Verify:** `npx tsc --noEmit`, validate each JSON parses, click live (toggle, Apply, copy URL,
76
+ reload → state persists).
77
+
78
+ ## Gotchas
79
+
80
+ - **No hardcoded copy in the component** — every user-facing string (incl. Close `aria-label`) is a
81
+ required `labels` prop from client FIELDS; fallbacks live in the view model's resolution, not the
82
+ component, not default prop values. Only structural glyphs (checkbox tick, close `xmark`) stay.
83
+ - **Remount key is REQUIRED.** toga-blox's `PrimaryTableRow` memoizes
84
+ `useMemo(() => row.getVisibleCells(), [row])`; TanStack caches `row` by data, so a columns-only
85
+ change doesn't change `row` → stale cells. Symptom: **toggling off removes the header but leaves the
86
+ body column.** `key={columnVisibilityKey}` forces a fresh table instance — the only fix without
87
+ editing `node_modules`. Real upstream fix: repair that memo in toga-blox.
88
+ - **Don't shrink the fetch meta** — adjust `isVisible` only on the table meta; `getDataTableData`
89
+ builds its field list from `meta.fields`.
90
+ - **`cols` must be stripped from the data query** or every toggle refetches.
91
+ - **`has("cols")` vs truthiness** — empty `cols` is a real "hide all" state, not "absent".
92
+ - **Checkbox can't be a nested `<button>`** — the row is the clickable `<button>`; the checkbox is a
93
+ visual `<span aria-hidden>`. A button-in-button is invalid HTML and silently breaks clicks.
94
+ - **Shareable-link trade-off** — an explicit `cols` set captures an exact view; columns added to the
95
+ meta later won't appear for an old link until Reset (intended).
96
+
97
+ ## Change history
98
+ - 2026-06-23 — Documented from the `add-column-visibility` skill during initial knowledge seed. (apeterson)
@@ -0,0 +1,176 @@
1
+ ---
2
+ title: Meta-Driven Page & Table Setup
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
13
+ - toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts
14
+ - toga25-supply/src/hooks/useTablePageMeta.ts
15
+ - toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts
16
+ - toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts
17
+ - toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts
18
+ - toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts
19
+ related:
20
+ - ../architecture.md
21
+ - column-visibility.md
22
+ - record-modals-and-nested-tables.md
23
+ ---
24
+
25
+ ## What it is
26
+
27
+ A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels,
28
+ sections, ACL) and *table meta* (the columns/fields + table settings), then fetches the *table
29
+ data* — each step keyed by a slug, each hook supplied by `@agilant/toga-blox`. A developer wires a
30
+ new page by composing these hooks in a view model; they never hand-build columns or choose a
31
+ fetch strategy. `useSalesOrdersViewModel`
32
+ (`src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx`) is the canonical example.
33
+
34
+ ## The flow (Sales Orders)
35
+
36
+ ```
37
+ useSalesOrdersViewModel
38
+ │
39
+ │ 1. PAGE META ── page slug "sales-orders-listing", app "supply"
40
+ ├─▶ useFetchPageMeta("sales-orders-listing", { app: "supply", enabled: true }) [toga-blox]
41
+ │ → pageMeta: PageMeta { settings: { sections, fields }, acl }
42
+ │ (field-label/section overrides + ACL for this page)
43
+ │
44
+ │ 2. TABLE SLUG ── from the route, not from pageMeta
45
+ ├─▶ useSalesOrdersTableState() → useServerTableUrlState(slug, …) [app]
46
+ │ slug = location.pathname.replace("/", "") // e.g. "salesorders"
47
+ │ also owns sorting / filters / page / recordsPerPage / activeRowUuid (URL state)
48
+ │
49
+ │ 3. TABLE META ── table slug → columns + table settings
50
+ ├─▶ useTablePageMeta({ slug: tableState.slug }) [app wrapper]
51
+ │ └─ useFetchTablePageMeta({ slug }) [toga-blox]
52
+ │ → { table: TableViewMeta, nestedTable }, isLoadingFilterOptions, …
53
+ │ table.fields[] = every column (TableViewField[])
54
+ │ table.{ slug, route, joins, sort, recordsPerPage,
55
+ │ isPaginationEnabled, isInfiniteScrollingEnabled, apiWhereClause }
56
+ │
57
+ │ 4. MERGE LABELS ── graft page-meta labels onto table fields
58
+ ├─▶ useAssignTableFieldLabels(fetchedTableViewMeta, pageMeta) [toga-blox]
59
+ │ → salesOrdersTableMeta: TableViewMeta | null
60
+ │
61
+ │ 5. TABLE DATA ── same table slug → rows
62
+ └─▶ useTableData({ slug: tableState.slug, tableViewMeta: salesOrdersTableMeta,
63
+ isPaginationEnabled: !!salesOrdersTableMeta?.isPaginationEnabled,
64
+ sorting, columnFilters, page, recordsPerPage,
65
+ searchParams, additionalData }) [toga-blox]
66
+ → { records, isLoadingTableDataLoading, isFetching,
67
+ paginationMeta, fetchNextPage, hasNextPage }
68
+ ```
69
+
70
+ The view model then returns `salesOrdersTableMeta` (after column-visibility adjustment — see
71
+ [column-visibility](column-visibility.md)), `displayRecords`, the pagination/infinite wiring, and
72
+ `tableSettings = { isPaginationEnabled }` for the `*TableLayout` to forward to
73
+ `PrimaryTableServerLayout`.
74
+
75
+ ### Two slugs, two metas — keep them straight
76
+
77
+ - **Page slug** (`"sales-orders-listing"`) is a literal passed to `useFetchPageMeta` and identifies
78
+ the *page* (its label overrides, sections, ACL). It does **not** identify the table.
79
+ - **Table slug** (`tableState.slug`) identifies the *table* and is used for **both** the table-meta
80
+ fetch (step 3) and the table-data fetch (step 5) — they must match so the columns and the rows
81
+ describe the same table. In Sales Orders this slug is derived from the route pathname via
82
+ `useServerTableUrlState`, not read out of `pageMeta`.
83
+ - `useAssignTableFieldLabels` is the bridge: it overlays the page meta's per-field labels
84
+ (`pageMeta.settings.fields[table][field].label`) onto the table meta's fields, so page-scoped
85
+ copy wins without re-fetching.
86
+
87
+ ## What's in the table meta (the columns)
88
+
89
+ `useFetchTablePageMeta` returns `{ table, nestedTable }`. The `table` object is the `TableViewMeta`
90
+ (alias of `DataTableMetaProp`) and carries:
91
+
92
+ - `fields: TableViewField[]` — **every column**. Each field: `slug` (unique id / tanstack column
93
+ id), `label`, `isVisible`, `index`, `type` (`STRING | NUMBER | DATE | DATETIME | SELECT |
94
+ MULTISELECT | IMAGE | HYPERLINK | STATUS | CURRENCY | AVATAR | LINK | URGENCY | CLIENT |
95
+ STATUS_BADGE | BOOLEAN | null`), `isEditable`, `isSortable`, `isFilterable`, `isCopyable`,
96
+ `isExpandableCell`, `width`, `sticky` (`left|right|null`), `precision`, `casing`, `recordRoute`,
97
+ `hyperlinkField`, `imageUrlField`, `filterOptions`, `settings: { table, field, contextTable,
98
+ contextField }`, `selectIdentifierField`, `selectNameField`.
99
+ - Table-level: `slug`, `route`, `table`, `joins[]`, `sort[]`, `recordsPerPage`, `apiWhereClause`,
100
+ `isPaginationEnabled`, `isInfiniteScrollingEnabled`.
101
+ - `nestedTable` — a second `TableViewMeta | null` for nested/expand rows.
102
+
103
+ `buildTanstackColumns` (toga-blox) builds columns from `fields`, filtering by `isVisible` (the
104
+ tanstack column `id` is the field `slug`).
105
+
106
+ ## Pagination vs. infinite scroll is meta-driven too
107
+
108
+ `useTableData` runs **both** a paginated `useQuery` and an infinite `useInfiniteQuery`, gating each
109
+ with `enabled` on `isPaginationEnabled` so only the active one fetches, and returns one normalized
110
+ shape (`records`, `paginationMeta` *or* `fetchNextPage`/`hasNextPage`). The view model passes only
111
+ `isPaginationEnabled` (from the table meta); `PrimaryTable*Layout` derives
112
+ `isInfiniteScrollingEnabled = !isPaginationEnabled`. So a table's mode comes from its meta, never a
113
+ per-page hook choice. Other baked-in behavior: unified `["table-data", slug, …]` query key (a
114
+ blanket `invalidateQueries({ queryKey: ["table-data"] })` refetches either mode); display params
115
+ (`<slug>` active-row uuid, `cols`) stripped from the key/API call; generic record extraction (first
116
+ array in `response.data`).
117
+
118
+ ## From toga-blox `dist` (reference)
119
+
120
+ Everything in the pipeline except the route-derived table slug comes from `@agilant/toga-blox`. The
121
+ app imports the built `dist/` — edits to toga-blox source require a rebuild (`cd toga-blox-npm &&
122
+ npm run build`) before they reach runtime.
123
+
124
+ | Export | `dist` path | Signature / role |
125
+ |---|---|---|
126
+ | `useFetchPageMeta` | `dist/hooks/useFetchPageMeta` | `(slug, { app, enabled?, onError? }) → { pageMeta: PageMeta \| null, labels, isLoading, isError, refetch }`. `PageMeta = { settings: { sections: MetaSections, fields: Record<string,any> }, acl }`. Page-level labels/sections/ACL. |
127
+ | `useFetchTablePageMeta` | `dist/hooks/useFetchTablePageMeta` | `({ slug }) → { tableViewMeta: { table, nestedTable }, isLoadingFilterOptions, isLoadingTableDataLoading, isFetching }`. The columns + table settings. |
128
+ | `useAssignTableFieldLabels` | `dist/hooks/useAssignTableFieldLabels` | `(fetchedTableViewMeta: { table? } \| null, pageMeta: { settings?: { fields? } } \| null) → TableViewMeta \| null`. Merges page-meta field labels onto table fields. |
129
+ | `useTableData` | `dist/components/Table/hooks/useTableData` | `(UseTableDataOptions) → { records, isLoadingTableDataLoading, isFetching, paginationMeta, fetchNextPage, hasNextPage }`. Bimodal data fetch (see above). |
130
+ | `TableViewMeta` (type) | `dist/components/Table/types` → `= DataTableMetaProp` | The `table`/`nestedTable` shape; `TableSkin = "supply" \| "desk" \| "supply-nested" \| "supply-modal-table"`. |
131
+ | `TableViewField` (type) | `dist/api/index` | Per-column shape (see "What's in the table meta"). |
132
+
133
+ App-side glue (NOT in toga-blox):
134
+ - `useTablePageMeta` (`src/hooks/useTablePageMeta.ts`) — thin wrapper over `useFetchTablePageMeta`
135
+ that re-types `table`/`nestedTable` as `TableViewMeta | null` and re-exposes the rest unchanged.
136
+ - `useSalesOrdersTableState` → `useServerTableUrlState` (`src/hooks/`) — derives the **table slug**
137
+ from the route pathname and owns URL-backed sorting/filters/pagination/active-row state.
138
+
139
+ ## Wiring a new page (recipe)
140
+
141
+ 1. `useFetchPageMeta("<page-slug>", { app: "supply", enabled: true })` → `pageMeta`.
142
+ 2. A table-state hook (`use<Page>TableState` → `useServerTableUrlState(slug, …)`) to get the table
143
+ `slug` + URL state (`sorting`, `columnFilters`, `page`, `recordsPerPage`, `searchParams`).
144
+ 3. `useTablePageMeta({ slug })` → `tableViewMeta` (columns + settings).
145
+ 4. `useAssignTableFieldLabels(tableViewMeta, pageMeta)` → the merged meta you hand to the table.
146
+ 5. `useTableData({ slug, tableViewMeta: merged, isPaginationEnabled: !!merged?.isPaginationEnabled,
147
+ sorting, columnFilters, page, recordsPerPage, searchParams, additionalData })` → rows.
148
+ 6. `tableSettings = useMemo(() => ({ isPaginationEnabled: merged?.isPaginationEnabled }), [merged])`.
149
+ 7. Return both pagination wiring (`paginationMeta`, `handlePageChange`, `handleRecordsPerPageChange`)
150
+ **and** infinite wiring (`fetchNextPage`, `hasNextPage`); forward all from the `*TableLayout` to
151
+ `PrimaryTableServerLayout`.
152
+
153
+ ## Gotchas
154
+
155
+ - **Page slug ≠ table slug.** The page slug is a literal for `useFetchPageMeta`; the table slug
156
+ (route-derived here) drives table meta *and* table data — those two must use the **same** slug.
157
+ - **Server meta currently returns `isPaginationEnabled: true` for every slug**, so all tables
158
+ paginate today. A table that should infinite-scroll needs the backend to return `false` for that
159
+ slug — the frontend no longer hardcodes it. (This is why VendorItems, historically infinite, now
160
+ paginates.)
161
+ - **Don't shrink the fetch meta for column hiding.** `getDataTableData` builds its API field list
162
+ from `meta.fields`; adjust `isVisible` only on the *display* meta (see column-visibility).
163
+ - **Infinite scroll needs a height-constrained scroll container** (`max-height` + `overflow-y: auto`);
164
+ inline-expanded nested tables where the page scrolls won't load more. Pagination is unaffected.
165
+ - **`additionalData` must be in the query key** (it is, inside `useTableData`) — required for
166
+ WHERE-filtered modal tables or React Query serves a stale unfiltered first render.
167
+ - **toga-blox edits need a rebuild** — the app consumes `dist/`; editing only `src` is a silent no-op.
168
+
169
+ ## Change history
170
+ - 2026-06-23 — Refocused on the full page→table→data meta pipeline (page meta via `useFetchPageMeta`,
171
+ table meta via `useTablePageMeta`/`useFetchTablePageMeta`, label merge via
172
+ `useAssignTableFieldLabels`, data via `useTableData`), grounded in `useSalesOrdersViewModel`; added
173
+ a toga-blox `dist` reference table. (apeterson)
174
+ - 2026-06-09→06-10 — Migration that established the bimodal `useTableData`: added it to toga-blox;
175
+ made `PrimaryTable*Layout` derive `isInfiniteScrollingEnabled`; migrated Items/SalesOrders/VendorItems
176
+ view models + `GenericNestedTables` + `ItemFulfillmentModal`; deleted per-page data hooks. (apeterson)
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: Record Modals & Nested Tables
3
+ framework: "2.0"
4
+ repo: toga25-supply
5
+ project: TOGa 2.5 Supply
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-23
10
+ owners: [apeterson]
11
+ files:
12
+ - toga25-supply/src/layout/ItemRecordModalLayout/
13
+ - toga25-supply/src/layout/SalesOrderRecordModalLayout/
14
+ - toga25-supply/src/layout/SalesOrderItemsTableLayout/
15
+ - toga25-supply/src/layout/ItemFulfillmentModal/
16
+ - toga25-supply/src/layout/GenericNestedTables/
17
+ - toga25-supply/src/hooks/useTableCellInteractions.ts
18
+ related:
19
+ - ../architecture.md
20
+ - meta-driven-table-data.md
21
+ ---
22
+
23
+ ## What it is
24
+
25
+ The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and
26
+ `PrimaryTable*Layout`. Covers: row-click record modals, nested client/server tables inside a
27
+ modal, cell-click modals, modal-contained server tables, the config-driven `GenericNestedTables`
28
+ renderer, and form-driven create/edit record modals. The canonical examples live in
29
+ `src/layout/*` (Sales Orders + Item). These six patterns are the load-bearing UI architecture for
30
+ this app.
31
+
32
+ ## Pattern 1 — Row click opens a record modal (URL-driven)
33
+
34
+ Page renders a `*TableLayout` and passes a `renderModal` prop. The table layout owns all state via
35
+ `use*ViewModel` → `useServerTableUrlState`. Clicking a row sets `activeRowUuid` in the URL;
36
+ `PrimaryTableServerLayout` calls `renderModal({ uuid, onClose })`, which renders the record modal
37
+ wrapped in `TableRecordModal position="bottom"`.
38
+
39
+ - `renderModal` is `({ uuid, onClose }) => ReactNode | null` — return `null` when `uuid` is falsy.
40
+ - `onClose` clears the URL state (`handleModalClose`).
41
+ - The record modal layout calls its own view model to fetch data for that uuid.
42
+ - **Action-column modals** (e.g. approval) use a separate `useState<Record|null>` in the page,
43
+ rendered as a second `TableRecordModal position="center"` — not URL-driven.
44
+
45
+ ## Pattern 2 — Record modal contains a nested client-side table
46
+
47
+ The record modal view renders e.g. `SalesOrderItemsTableLayout` with `displayRecords` from the
48
+ parent view model. The nested table uses `PrimaryTableClientLayout` (data already in memory),
49
+ `tableSkin="supply-modal-table"`, and its **own** slug + `useSearchParams` + `useServerTableUrlState`
50
+ so URL-state namespacing doesn't collide with the outer table. Column widths come from
51
+ `src/pages/<Entity>/dummyData/*.json`; `headerSpan` adds a spanning header above grouped columns.
52
+
53
+ ## Pattern 3 — Cell click within a nested table opens another modal
54
+
55
+ Driven by the reusable hook `useTableCellInteractions` (`src/hooks/useTableCellInteractions.ts`),
56
+ which owns all click/hover/style logic. Each table's view model only defines the registries:
57
+
58
+ ```ts
59
+ const { activeModal, closeModal, handleTableClick, handleTableMouseOver,
60
+ handleTableMouseOut, applyClickableStyles } = useTableCellInteractions({
61
+ fields, cellClickRegistry, headerClickRegistry /* optional */, contextUuid /* optional */ });
62
+ ```
63
+
64
+ - `cellClickRegistry[key]` (key matches the field's `onClick` in the table meta JSON):
65
+ `{ canOpen?(row), getModalSpec(row) => ({ key, uuid, row }) }`. `canOpen` is the single source
66
+ of "is this cell clickable?" — used by the click handler, hover styles, and cursor styles.
67
+ - `headerClickRegistry[key]` (key matches the field's `headerOnClick`):
68
+ `{ getModalSpec(contextUuid) => ({ key, uuid, row }) }`.
69
+ - Table meta JSON marks a field: `{ "name": "qtyFulfilled", "onClick": "openFulfilledModal",
70
+ "headerOnClick": "openAllFulfillments" }` — both independent.
71
+
72
+ Layout setup: wrap the table in a div with `onClick={(e) => handleTableClick(e, displayRecords)}`
73
+ + mouseover/out; re-apply styles on DOM changes via a `MutationObserver` calling
74
+ `applyClickableStyles` (virtual scroll re-renders rows); render each modal as a sibling switching
75
+ on `activeModal.key`. **Adding a clickable column = 3 edits:** meta JSON `onClick`, one
76
+ `cellClickRegistry` entry, one `{activeModal?.key === "myKey" && <Modal/>}` block.
77
+
78
+ ## Pattern 4 — Modal containing a server-side table
79
+
80
+ For modal tables needing server data, use `PrimaryTableServerLayout` inside `TableRecordModal
81
+ position="bottom"`. The modal view model calls `useFetchTablePageMeta` + `useTablePageData`
82
+ (or `useTableData`) with an `additionalData` WHERE filter, e.g. `{ slug: "SalesOrderItems.uuid",
83
+ uuid: "<row-uuid>" }`. **`additionalData` MUST be in the query key** (in toga-blox) or React Query
84
+ caches the unfiltered first render (uuid null) and never refetches. Guard with `isOpen={!!uuid}`;
85
+ when uuid is null, `additionalData` returns `{}`. Typically `isPaginationEnabled: false` +
86
+ `isInfiniteScrollingEnabled: true` (but see [meta-driven-table-data](meta-driven-table-data.md)).
87
+
88
+ ## Pattern 5 — `GenericNestedTables` (config-driven multi-level / swap tables)
89
+
90
+ A config-driven renderer: declare an array of `TableLevel` objects instead of hand-wiring each
91
+ level. Each level fetches its own meta + data and renders a `GenericTableLayout` (wraps
92
+ `PrimaryTableServerLayout`). Reference: `src/pages/Inventory/`.
93
+
94
+ `TableLevel` carries `fetchSlug`, `metaVariant`, `tableSkin`/`swapTableSkin`, `actionColumns`,
95
+ `renderModal` (Pattern 1), `rowInteraction` (`"expand"` default | `"swap"`), `urlParamKey`
96
+ (required for swap, unique per level), `getAdditionalData(parentUuid)`,
97
+ `getSwapAdditionalData(selfUuid)`.
98
+
99
+ - **`"expand"`** — clicking a row inline-expands the next level beneath it (filtered via
100
+ `getAdditionalData(parentUuid)`).
101
+ - **`"swap"`** — clicking writes `urlParamKey=<rowUuid>`; the renderer makes that level the new
102
+ active root, filtered by `getSwapAdditionalData(selfUuid)` (using `swapTableSkin`).
103
+
104
+ `additionalData` is produced per-level by callbacks (the WHERE-filter shape from Pattern 4). Each
105
+ level runs its own `useServerTableUrlState` and strips its own slug key, isolating URL state.
106
+ A page-level `pageMeta` (from `useFetchPageMeta`) flows down for field-label overrides. The page
107
+ view model builds `TableLevel[]` arrays, picks the active one via a URL param, and passes `levels`
108
+ to `GenericNestedTables` with `key={activeGrouping.key}` to force remount and avoid stale state.
109
+
110
+ ## Pattern 6 — Create/edit record modal (form-driven)
111
+
112
+ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLayout/`). Form-driven
113
+ (react-hook-form + `FormProvider`), fields rendered through a section/field config + `DetailSection`
114
+ + `BaseInput`, inside `TableRecordModal position="bottom" variant="expandable"`.
115
+
116
+ ```
117
+ <Entity>RecordCreateModalLayout ← owns form hook, mounts TableRecordModal
118
+ └─ <Entity>RecordCreate ← FormProvider + form, renders sections
119
+ ├─ use<Entity>CreateForm ← rhf state + apiPost submit
120
+ └─ use<Entity>RecordEditViewModel ← fetches advancedSelect options
121
+ ```
122
+
123
+ - Form hook: `useForm({ defaultValues, mode: "onChange", reValidateMode: "onChange" })`; `onSubmit`
124
+ builds a payload (map relation objects to `.uuid`), `apiPost("/<entities>", payload)`, then
125
+ `queryClient.invalidateQueries({ queryKey: ["table-data"] })`, reset, `onCreated?.()`. Edit
126
+ variant: `form.reset(record)` in a `useEffect`, `apiPut`, expose `isEditing`/`onEnableEdit`/`onCancelEdit`.
127
+ - Field types: `text`, `textArea`, `toggle`, `advancedSelect`, `advancedMultiSelect`; mark required
128
+ with `isRequired` + `showRequiredIndicator`; feed select options via `optionsByFieldUuid` keyed by
129
+ field `uuid`. The edit view model that supplies options is **reused** by both create and edit.
130
+ - Page holds `isCreateOpen` state + a "New <Entity>" `BaseButton`, renders the layout with `isOpen`/`onClose`.
131
+
132
+ ## Gotchas
133
+
134
+ - **Files use TABS** — match exactly or edits won't apply.
135
+ - **`BaseButton` is a default import:** `@agilant/toga-blox/dist/components/BaseButton/BaseButton.js`.
136
+ - **Submit payload diverges from form shape** — send relation `.uuid`, not the `{uuid,name}` object;
137
+ confirm field names against the real POST/PUT contract.
138
+ - **Nested tables need their own slug + URL-state isolation** or sibling tables clobber each other.
139
+ - **`additionalData` in the query key is mandatory** for WHERE-filtered modal tables (Pattern 4).
140
+ - **Per-consumer cache `scope`** — two consumers fetching the same record with different field
141
+ configs must use a `scope` discriminator in the query key or they overwrite each other's cache
142
+ (record modal vs. approval modal: the approval modal omitting item-image fields blanked the
143
+ record modal's images until scoping isolated the entries).
144
+
145
+ ## Change history
146
+ - 2026-06-23 — Documented from CLAUDE.md Modal Patterns 1–5 + the `create-record-modal` skill during
147
+ initial knowledge seed. (apeterson)
@@ -27,6 +27,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
27
27
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
28
28
  - **ai-bdr** (AI-BDR) — 4 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
29
29
  - **toga2-commerce** (TOGa Commerce) — 2 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
30
+ - **toga25-supply** (TOGa 2.5 Supply) — 5 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
30
31
 
31
32
  ## standalone framework
32
33
 
@@ -49,4 +50,5 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
49
50
  - **Rate** (`rate`) → [clients/rate/INDEX.md](clients/rate/INDEX.md)
50
51
  - **Tow Foundation** (`tow-foundation`) → [clients/tow-foundation/INDEX.md](clients/tow-foundation/INDEX.md)
51
52
  - **Walmart Client Profile** (`walmart`) → [clients/walmart/INDEX.md](clients/walmart/INDEX.md)
53
+ - **Wiss, Janney, Elstner Associates, Inc.** (`wje`) → [clients/wje/INDEX.md](clients/wje/INDEX.md)
52
54
 
@@ -0,0 +1,5 @@
1
+ # Client: Wiss, Janney, Elstner Associates, Inc. `wje`
2
+
3
+ | Doc | Framework | Summary | Files |
4
+ |-----|-----------|---------|-------|
5
+ | [Wiss, Janney, Elstner Associates, Inc.](profile.md) | 2.0 | Wiss, Janney, Elstner Associates, Inc. | worker/crons/toga2/wje/sync_togasupply_wje.php |
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: "Wiss, Janney, Elstner Associates, Inc."
3
+ framework: "2.0"
4
+ apps:
5
+ - worker
6
+ - library
7
+ project: Library
8
+ client: wje
9
+ type: profile
10
+ status: active
11
+ updated: 2026-06-23
12
+ owners: [jcardinal]
13
+ files:
14
+ - worker/crons/toga2/wje/sync_togasupply_wje.php
15
+ related:
16
+ - ../../1.0/apps/library/features/toga2-api-client-and-bridge.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ Wiss, Janney, Elstner Associates, Inc. (WJE) is a TOGa Desk client integrated through the
22
+ **1.0↔2.0 TOGaDesk bridge** (`App_Api_Toga2::syncWithTogadesk`). The cron
23
+ `worker/crons/toga2/wje/sync_togasupply_wje.php` runs every 2 minutes and enables the **full**
24
+ set of bridge sub-syncs (tickets both directions, ticket-notes, contacts→people, items/units→
25
+ assets, predefined-replies, ticket-teams→groups, ticket-categories) for TOGaDesk client id 153,
26
+ department 269 (plus internal-tickets department 290). WJE requires a group on every ticket and
27
+ falls back to a default group/agent when none is assigned.
28
+
29
+ See the bridge feature doc for the mechanism. No NetSuite TOGa Supply importer runs for WJE
30
+ (it is a help-desk integration, not a supply/procurement client).
@@ -158,9 +158,7 @@
158
158
  "project": "TOGa Commerce",
159
159
  "framework": "2.0",
160
160
  "role": "app",
161
- "dependsOn": [
162
- "api2"
163
- ]
161
+ "dependsOn": ["api2"]
164
162
  },
165
163
  {
166
164
  "repo": "toga",
@@ -170,5 +168,12 @@
170
168
  "dependsOn": [
171
169
  "library"
172
170
  ]
171
+ },
172
+ {
173
+ "repo": "toga25-supply",
174
+ "project": "TOGa 2.5 Supply",
175
+ "framework": "2.0",
176
+ "role": "app",
177
+ "dependsOn": []
173
178
  }
174
179
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.162",
3
+ "version": "1.0.164",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",