The dialog's wording, its actions and its behaviour all diverged from the
design prototype. It refused deactivation outright when the vendor had
open work orders, which SH-44 and SH-82 left as an open question rather
than a decided requirement.
Deactivation with open work orders now proceeds on explicit confirmation:
the design's title and body copy, the affected work orders listed as
links, and "Deactivate anyway" in place of a disabled button. The API
call carries confirmOpenWorkOrders so the server-side guard is cleared
deliberately rather than removed.
Three divergences in the vendor detail drawer:
- The header rendered the company as a subtitle under a title that had
already fallen back to the company, printing the same name twice when
the row carried no technician. The subtitle is now dropped when it
would repeat the title.
- The Google Maps link appeared only when a URL was stored, unlabelled,
so an empty value left no trace of the field. It is now a labelled
field below Address that reads em-dash when unset, as every other
field in the panel does.
- The status sat immediately beside Total Jobs. It now sits opposite it
at the right edge, and reuses the vendors table's dot-plus-text badge
so the state never reads by colour alone.
The drawer's Active toggle asked to deactivate whenever the switch moved
from on to off, reading the unsaved form value. An inactive vendor toggled
on and back off without saving therefore raised "Deactivate this vendor"
for a vendor that was already inactive.
Gate on the persisted technician status from the loaded roster instead,
falling back to the clicked row. Deactivation is a property of the saved
vendor, not of the toggle.
The reload fix re-seeded the form from queryClient.fetchQuery, which inherits the
global 5-minute staleTime. A 409 never invalidates that key -
useSaveVendorCompanyRoster invalidates only in onSuccess and its onError returns
early for conflicts - and the Add flow has no mounted observer on it, so reload
returned the cached roster with the same stale rowVersion and every retry 409'd
again. Update mode was unaffected because it recovers via query.refetch(), which
bypasses staleTime.
Pass staleTime: 0 on that fetch, since its sole purpose is the freshest
rowVersion. The regression test uses a client with the app's real staleTime; the
default test client uses 0, which masked this entirely.
The loading-aware gate pushed VendorTradeSpecialtiesField to 151 lines, one over
the changed-file maintainability cap. Move the rejection-message resolution and
the contract explanation to module scope; no behavior change.
Three review findings on the additive add path:
- Create-mode reload refreshed selectedRoster (including rowVersion) but left
the form company fields on their pre-conflict values, so a retry diffed stale
fields against the reloaded roster and could overwrite the concurrent company
update that caused the 409. Reload now resets the form from the fresh roster,
matching the select path.
- mapVendorRosterAdditivePatchToBackend copied companyFields verbatim while the
reconcile write mapper canonicalizes companyPhone, so PATCH and PUT could send
different phone shapes for the same input.
- emptyRosterTechnician seeded preferredContact "Phone" even though that
control was removed from the form, so filling the first blank row submitted a
fabricated value. append already omitted it; both paths now agree.
An empty tradeOptions array meant two different things: "facets query still in
flight" and "vocabulary genuinely unavailable". Both enabled fail-open free
text and showed the outage warning, so a normal cold fetch looked like an
outage. Anything committed in that window was added to knownTradesRef and stayed
selectable after the canonical list arrived, permanently undermining the gate.
Expose the facets query isLoading as tradesLoading, thread it to the field as
tradeOptionsLoading, and keep the canonical gate active while loading. The
outage warning now shows only once the query has settled empty.
The pinned Unassigned section folded the queue query's pending state into
isFetching only, which the section never read. On first mount, once GET /board
resolved while the queue was still paging GET /board/search, the pin announced
"No unassigned work orders." instead of a loading state.
Expose the queue query's own isLoading as unassignedLoading, thread it through
the table data hook, and render an aria-busy loading row while it is true so the
loading and empty states are separately observable.