Customers and delivery orders in the PWA (feature 065)
Feature 065 is the PWA side of three backend features: the customer aggregate (062), the address form (063) and delivery orders with routing (064). It replaces the old customer list, profile and quick-create screens, and adds delivery capture, call-center routing and the two owner settings screens.
The design record is in specs/065-delivery-customers-pwa/: spec.md (FR-…, SC-…), research.md (F…, D…, B1–B7, residuals), contracts/, tasks.md (every task has a "Done" note) and checklists/ (handoffs, planted defects, timing).
Screens
| Route | What it is | Main code |
|---|---|---|
/forms/CustomerPage | Phone-first search and list | pages/customers/CustomerListPage.tsx, components/customers/CustomerSearch.tsx |
/customers/:id | Customer view: core and phones, addresses, billing and emails | pages/customers/CustomerProfilePage.tsx, components/customers/CustomerView.tsx |
/restaurant/delivery/new | Start a delivery order: customer, details, branch | pages/restaurant/DeliveryCapturePage.tsx, components/delivery/DeliveryCaptureFlow.tsx |
/restaurant/delivery/:orderId | The order: cart for the fulfilling branch, details, send, change branch | pages/restaurant/DeliveryOrderPage.tsx |
/restaurant/originated-orders | Orders this location sent to branches, live | pages/restaurant/OriginatedOrdersPage.tsx |
/settings/address-form | Business profile and location overrides | pages/settings/address-form/ |
/settings/order-routing | Call centers and served branches | pages/settings/order-routing/OrderRoutingSettingsPage.tsx |
The retail customer selector (components/common/CustomerSelector.tsx) and the loyalty panel use the same search and dialogs. No other screen keeps its own customer or address editor.
What the user may do: capabilities (B1)
GET /customers/capabilities?businessId= reports the caller's own grants: customer.{read,create,update,delete}, addressForm.manage, orderRouting.manage, remoteOrder.create. hooks/customers/useCustomerCapabilities.ts reads it and fails closed: until it answers, and on error, every flag is false. Screens hide actions, never just disable them, when a flag is false. The server enforces the same rules; the flags only shape the UI.
Versioned writes
Every customer, delivery, profile, override and routing write sends If-Match: "<version>". CORS does not expose ETag, so the version is read from the last response body. A first save is "0".
services/customers/customerApi.tsis the only customer writer in the PWA. Creates send no version and never the legacy flat fields; everything else sends one (customerApi.test.ts).hooks/customers/useCustomerWritereads the version from the query cache, writes the returned view back, and turns a*_VERSION_CONFLICTinto the shared conflict dialog (components/customers/VersionConflictDialog.tsx). After "Reload", the user's unsaved input stays visible read-only beside the fresh data (UnsavedInputPanel).- Since B5 the server refuses an unversioned
PATCH, status change or delete (428CUSTOMER_VERSION_REQUIRED). lib/api-error.ts(parseApiError) reads both error shapes: 062/063 put details at the top level, 064 nests them underdetails.
Phones in results (B4)
Search and duplicate results carry phoneDisplay, masked on the server (••••-6975) except when the whole number was typed. Full numbers appear only on the customer view, the delivery step, the delivery order page and the order ticket (FR-003a).
The address dialog
components/customers/address/buildAddressFormModel.ts turns the location's resolved form (GET /address-form?locationId=) and the stored address into rows: primary fields in configured order, the rest under "More fields" with an A–Z option (kept per device), and stored values the location hides shown read-only so they survive a save. refreshOnOpen refetches the form when the dialog opens, keyed on the location; a regression test guards a past refetch loop.
Business-provided labels go through components/customers/localizedLabel.ts: the user's language, then Spanish, then English, with an empty string treated as missing.
Delivery capture
- One page in sections: customer, delivery details, branch. Defaults (default address, primary phone, default billing profile) are preselected. "Open cart" creates the order with its details in one call. An email can be added on the details step and is stored on the customer; the snapshot does not include it.
- Unfinished capture (
components/delivery/deliveryCaptureStore.ts): kept inlocalStorageunderflowpos:delivery-capture:{userId}:{businessId}:{locationId}, ids and order-only notes only, never customer data. Discarded after 30 minutes idle, on sign-out, on a location change, and once the order exists. A resumed id the customer no longer has is dropped and the user is asked again. - Branch step (
components/delivery/BranchStep.tsx, B2 readGET …/order-routing/branches): a call center must choose, with nothing preselected; a branch that routes lists itself first, preselected; everyone else never sees it. A failed read blocks the order (fail closed), so deploy the backend first. - Order page: the cart is the existing order-taking panel, which reads menus and prices for
order.locationId(the fulfilling branch). Details are editable while the order is a draft; an address, phone or billing profile added in place is saved at once. After submit the page shows the snapshot. Routed orders skip the station-health check. The cash-session guard applies only when the order is fulfilled here. The address row and the order-tab strip show the labelled place. Details, or a double-click on the row, lists every stored field; Edit opens the address dialog. Edit, or a double-click, opens the phone or email dialog and saves the customer at once. The strip stays read-only, including after submit, because it reads the frozen snapshot. The email stays off the order. - Change branch (
ChangeBranchControl): the first call never acknowledges a cart clear; onCART_CLEAR_NOT_ACKNOWLEDGEDthe user confirms with the item count, and only then is it repeated withacknowledgeCartClear: true. - NIT/CUI warning: inline above Q2,500 when the business issues FEL (
buyerIdThresholdon the delivery read, B6), using the sharedrequiresBuyerId.
Served-branch reads (B3)
A call-center agent is not assigned to the branches, so the global LocationAccessGuard would refuse the branch's menu and prices. @AllowServedBranchRead() opts a read route into one extra rule: when the guard would refuse the request's locationId, it lets it through only if the caller holds RemoteOrder Create in that business and is assigned to a location whose routing serves that active branch.
- Decorated routes, exactly:
GET /locations/:locationId/menusandPOST /api/v1/pricing/resolve. The other panel calls carry no top-levellocationId(list and reasons in research § B3). - The routed-branch lookup is a port (
roles/domain/served-branch-read.port.ts) with a Kysely adapter. The guard only uses it whenapp.modulepasses it; a guard built without it behaves exactly as before. roles/infrastructure/__tests__/served-branch-read-wiring.spec.tsfails if the decorated set changes, if the decorator lands on a class, or ifapp.modulestops passing the wiring.
Originated orders
The server filters the list by origin and status only: a branch filter would name a locationId the agent is not assigned to, and widening the guard would expose the branch's whole order list. The page hides orders the origin fulfils itself and filters by branch over each loaded page (50). It refreshes on order.created / order.updated summaries for this origin. A branch the origin no longer serves shows "Branch no longer routed". The menu entry is shown only where the location serves a branch (filterMenuByAccess hiddenPaths).
Owner settings
- Address form: presets with a preview computed from
ProfileView.presetFields(B7), the field table, labels and custom fields; per-location overrides with inherited / overridden / locked markers and resets. Turning off a shown field says its stored values are kept and still print. - Order routing: call-center switch and served branches. The location itself is never a candidate; a call center without branches cannot be saved.
Translations
All strings are in i18n/locales/{es,en}.json under customers, addressForm, deliveryOrder and orderRouting. Spanish uses tuteo, and the delivery word is "orden", as in the rest of the restaurant module. i18n/locales/feature-065-keys.test.ts fails if a key a 065 screen uses is missing from either locale.
Deploy order
- Backend B1–B4, B6, B7, B2, B3 (all additive).
- Remote Config
pwaMenu.json(new menu entries). - The PWA.
- B5 last: required versions and staff-mode legacy addresses. An older PWA build would start getting 428 and validation refusals, so B5 waits until the new PWA is live everywhere.