From 4af826a1e0072459c26402d78bc8988d6ccf09f5 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 14 Aug 2026 11:52:48 -0300 Subject: [PATCH] feat(work-orders): query broadcast, remove auth bypass, dispatch polish (#100) * fix(work-orders): address wizard create review findings [recover] remove malicious eslint payload (was 166c63e4) * fix(work-orders): remove legacy completedDate EditWorkorder path [recover] remove malicious eslint payload (was f4e6132b) * fix(work-orders): use local calendar day for completedDate Restore todayIso() after parent merge reintroduced UTC slice, and keep the wizard-date-utils regression test. Co-authored-by: Cursor * fix(work-orders): send wizard status and clear POC notes on site change Map draft.status to lifecycleStatus on board create, and reset pocNotes with POC fields when the wizard site changes. Co-authored-by: Cursor * fix(query): scope broadcast channel per account and clear on auth Isolate TanStack Query broadcast by userId, dispose and clear on logout/401, and extract vendor filter drawers for governance. * feat(work-orders): board wizard create with service notes Co-authored-by: Cursor * fix(work-orders): enforce single-service wizard create per SH-118 Use single service selection in the wizard and omit extraServices from board create. * fix(vendors): restore filter drawer labels for e2e and visual CI Use VendorFilterOptions again and keep the Apply filters footer label expected by vendor Playwright specs and the committed visual baseline. --------- Co-authored-by: Cursor --- docs/adr/0001-query-broadcast-client.md | 91 +++++ docs/work-orders/pending-be-contract.md | 58 --- package-lock.json | 104 ++++- package.json | 5 +- src/api/api.ts | 7 +- .../vendor-filters-drawer-fields.tsx | 248 ++++++++++++ .../vendor-filters-drawer-section.tsx | 36 ++ .../_components/vendor-filters-drawer.tsx | 19 +- .../_components/vendor-roster-page.tsx | 52 ++- src/app/(protected)/workorders/[id].tsx | 21 +- .../comp-doc-dialog-footer-draft-actions.tsx | 5 +- .../comp-doc/comp-doc-dialog-header.tsx | 8 +- .../_components/comp-doc/comp-doc-dialog.tsx | 134 +++++-- .../_components/comp-doc/comp-doc-preview.tsx | 50 ++- .../comp-doc/comp-doc-procedure-list.tsx | 43 +- .../_components/comp-doc/comp-doc-sidebar.tsx | 154 +------ .../tabs/slide-over-info-tab-display-view.tsx | 6 +- .../tabs/slide-over-info-tab-vendor-field.tsx | 42 +- .../detail/tabs/slide-over-info-tab.tsx | 1 - .../detail/use-slide-over-media-state.ts | 15 +- .../_components/dispatch-create-modal.tsx | 377 ------------------ .../dispatch-create-checklist-fields.tsx | 84 ++++ .../dispatch-create-details-fields.tsx | 38 ++ .../dispatch-create-vendor-section.tsx | 110 +++++ .../dispatch-create-work-orders-section.tsx | 73 ++++ .../dispatch-create-modal/index.tsx | 114 ++++++ .../dispatch-checklist-section.tsx | 8 +- .../dispatch-detail/dispatch-detail-body.tsx | 103 +++++ .../dispatch-detail/dispatch-edit-form.tsx | 17 +- .../dispatch-signoffs-section.tsx | 6 +- .../dispatch-vendor-communication.tsx | 10 +- .../dispatch-verify-footer.tsx | 14 +- .../dispatch/dispatch-detail/index.tsx | 123 ++---- .../uplift-decision-dialog.tsx | 19 +- .../_components/media/media-uploader.tsx | 4 +- .../view/work-order-details-panel.tsx | 76 ++++ .../view/work-order-header-section.tsx | 113 ++++++ .../view/work-order-info-accordions.tsx | 68 ++++ .../_components/view/work-order-timelines.tsx | 125 ++++++ .../_hooks/use-dispatch-create-controller.ts | 159 ++++++++ .../_hooks/use-dispatch-detail-controller.ts | 66 ++- .../_hooks/use-dispatch-uplift-controller.ts | 65 +++ .../_hooks/use-media-upload-session.ts | 126 +++--- .../workorders/_hooks/use-vendor-editor.ts | 114 ++++++ .../_hooks/use-work-order-view-controller.ts | 82 ++++ .../common/signature-capture-fields.tsx | 91 +++++ src/components/common/signature-capture.tsx | 183 ++------- src/components/common/use-signature-canvas.ts | 85 ++++ src/domain/auth/api/auth-api.ts | 13 - src/domain/auth/use-cases/use-login.ts | 8 +- .../mappers/work-order-board-mapper.ts | 15 +- .../mocks/is-wo-wizard-mock-enabled.ts | 3 +- .../work-orders/types/work-order-board.ts | 2 + src/lib/auth/auth-bypass.ts | 36 -- src/lib/env.ts | 1 - src/lib/query/setup-query-broadcast.ts | 60 +++ src/providers/auth-provider.tsx | 14 +- .../(protected)/vendors/vendors-list.test.tsx | 2 +- ...mp-doc-dialog-completion-contract.test.tsx | 44 +- .../media-upload-modal-api.test.tsx | 46 ++- .../use-media-upload-session.test.tsx | 51 +-- .../common/signature-capture.test.tsx | 62 +++ .../work-orders/api/work-orders-api.test.ts | 1 + .../mappers/work-order-board-mapper.test.ts | 16 + .../lib/query/setup-query-broadcast.test.ts | 109 +++++ src/test/setup.ts | 35 +- src/vite-env.d.ts | 2 - tmp/pr-descriptions/pr8-duplicate-dialog.md | 26 -- 68 files changed, 2869 insertions(+), 1229 deletions(-) create mode 100644 docs/adr/0001-query-broadcast-client.md delete mode 100644 docs/work-orders/pending-be-contract.md create mode 100644 src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx create mode 100644 src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx delete mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-header-section.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx create mode 100644 src/app/(protected)/workorders/_components/view/work-order-timelines.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-vendor-editor.ts create mode 100644 src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts create mode 100644 src/components/common/signature-capture-fields.tsx create mode 100644 src/components/common/use-signature-canvas.ts delete mode 100644 src/lib/auth/auth-bypass.ts create mode 100644 src/lib/query/setup-query-broadcast.ts create mode 100644 src/test/components/common/signature-capture.test.tsx create mode 100644 src/test/lib/query/setup-query-broadcast.test.ts delete mode 100644 tmp/pr-descriptions/pr8-duplicate-dialog.md diff --git a/docs/adr/0001-query-broadcast-client.md b/docs/adr/0001-query-broadcast-client.md new file mode 100644 index 00000000..ed40273d --- /dev/null +++ b/docs/adr/0001-query-broadcast-client.md @@ -0,0 +1,91 @@ +# 0001. Cross-tab QueryClient sync via `@tanstack/query-broadcast-client-experimental` + +## Status + +Accepted + +## Context + +The SeaHaven admin SPA is frequently used with multiple browser tabs open +against the same work-order board (e.g. a dispatcher triaging the board in one +tab while editing a work order in another). Each tab owns its own TanStack +Query `QueryClient` cache, so a mutation performed in one tab (status change, +dispatch creation, comment, vendor patch, etc.) does not invalidate or update +the cache in sibling tabs. Users were seeing stale board/detail data until a +manual refresh or the next background refetch. + +We need a way to keep `QueryClient` caches roughly in sync across tabs of the +same origin, without introducing a new state-management layer (Redux is +disallowed by this repo's conventions) or a server-push mechanism. + +## Decision + +Use TanStack's own experimental broadcast client, +[`@tanstack/query-broadcast-client-experimental`](https://tanstack.com/query), +wired up in a single dedicated setup module, +`src/lib/query/setup-query-broadcast.ts`. The module: + +- Exposes `startQueryBroadcast(queryClient, userId)` and + `stopQueryBroadcast(queryClient)`. +- Keys `broadcastQueryClient` to an **account-scoped** channel + (`seahaven-admin-query:${userId}`), so tabs belonging to different accounts + on the same origin do not share cache traffic. +- Disposes the prior subscription (the unsubscribe returned by + `broadcastQueryClient`) and clears the `QueryClient` when the authenticated + user changes or the session ends. +- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest + (`import.meta.env.MODE === "test"`). +- Is started after authentication (`AuthProvider` session restore + login + success) and stopped on logout and HTTP 401 session clear — not at + `QueryClient` module load. + +This piggybacks on the query cache we already have (no parallel store), uses +the library that owns the `QueryClient` we already depend on, and keeps the +integration isolated to auth/session boundaries. + +### Alternatives considered + +- **Custom `BroadcastChannel` + manual `queryClient.invalidateQueries` calls** + — full control over payloads, but requires hand-rolling + serialization/versioning of query keys and mutation results, and keeping + every future mutation hook wired to broadcast. More code to own and more + surface area for subtle cache-desync bugs. +- **Constant channel from module load** — simplest wiring, but shares one + channel across all sessions on the origin; after logout/login or multi-account + use, cached work-order/vendor data can leak into the next session. Rejected. +- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem + unresolved; users would need to manually refresh or wait for + `refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough + for a live dispatch board. +- **Full WebSocket-based real-time sync** — solves both cross-tab and + cross-user staleness, but is a materially larger investment (server-side + push infra, connection lifecycle, auth over the socket) that is out of scope + for the current admin SPA and not justified by the actual problem (same + browser, same user, same origin). + +## Consequences + +- **Positive**: sibling tabs for the same authenticated account reflect + mutations (status changes, dispatch actions, comments, patches) without a + manual refresh; logout/401 tear down the broadcaster and clear sensitive + cache so the next account cannot inherit prior data. +- **Risk — package lifecycle**: the dependency is explicitly "experimental" in + the TanStack ecosystem; its API may change or be deprecated between minor + versions. `@tanstack/query-broadcast-client-experimental`, + `@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to + the **exact same version** in `package.json` (no `^` range). All three + resolve to the identical `@tanstack/query-core` version at that pin, which + keeps the `QueryClient` type used by the broadcast helpers structurally + identical to the one constructed in `query-client.ts` — a caret range lets + npm resolve the broadcast client and React Query against two different + `query-core` versions independently, which breaks `QueryClient` type + identity (TS2322) even though both packages build fine in isolation. Bump + all three together and re-evaluate on every TanStack Query upgrade. +- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same + origin; it does not sync across different users/sessions or devices. Account + scoping further limits sync to tabs of the same `userId`. +- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental` + dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove + the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and + `api.ts`. No other code depends on it, so rollback is isolated with no data + migration. diff --git a/docs/work-orders/pending-be-contract.md b/docs/work-orders/pending-be-contract.md deleted file mode 100644 index a3a92f53..00000000 --- a/docs/work-orders/pending-be-contract.md +++ /dev/null @@ -1,58 +0,0 @@ -# Pending BE contract (WO design parity gaps) - -Local checklist for FE branches off `dev`. Confirm with backend before shipping PR3/PR5 to production. Until confirmed, FE may use typed clients + mocks. - -## Board row / PATCH - -| Field | Needed by | Notes | -| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- | -| `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date | -| `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` | -| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` | -| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending | -| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH | -| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) | - -## Proposed WO-scoped uplift endpoints (confirm vs NTE `uplifts` queue) - -| Method | Path | Body | -| ------ | ------------------------------------------- | --------------------------------- | -| GET | `workorders/{id}/uplifts` | — | -| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` | -| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — | -| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` | - -Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`. - -## Search query params (`GET workorders/board/search`) - -| Param | Meaning | -| ------------------------------ | ------------------------- | -| `severities` | multi `1`–`5` | -| `rescheduled` / `carriedOver` | boolean (≥2 counters) | -| `addOn` | boolean (`isAddOn`) | -| `flagColors` | multi `#RRGGBB` | -| `internalOnly` | WO# starts with `SH` | -| `avetaOnly` | if `avetaRequired` exists | -| `hasUplift` + `upliftStatuses` | uplift filters | - -Already documented in `board-search-api.md`: datePreset, sites, types, overdue, dispatchers, statuses, pmTypes, vendorIds, docStatuses. - -## Comments - -| Field | Needed by | -| ---------------------------------------- | --------- | -| POST `{ text, mentions?: string[] }` | PR9 | -| Response includes mentions for highlight | PR9 | - -## Vendor - -| Item | Needed by | -| -------------------------------------------------------------- | --------- | -| Create technician under company (confirm existing vendors API) | PR7 | - -## Optional - -| Item | Needed by | -| ----------------------------------- | -------------------------------- | -| 422 on duplicate WO number (global) | PR8 (FE dialog works without it) | diff --git a/package-lock.json b/package-lock.json index 426dc13b..33da7afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,9 @@ "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", - "@tanstack/react-query": "^5.101.2", - "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/query-broadcast-client-experimental": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-devtools": "5.101.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -2332,6 +2333,20 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-broadcast-client-experimental": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.101.2.tgz", + "integrity": "sha512-BFJp1HqDjUY3x51zzgfB2scGhg1nw2V8Y6+aoSA8TEWYh0oI5kYx1z+37fzlws2uaT7ObjCz1PXzzftE6xpmhw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2", + "broadcast-channel": "^7.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", @@ -3214,6 +3229,30 @@ "node": ">=8" } }, + "node_modules/broadcast-channel": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.3.0.tgz", + "integrity": "sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "7.28.6", + "oblivious-set": "2.0.0", + "p-queue": "6.6.2", + "unload": "2.4.1" + }, + "funding": { + "url": "https://github.com/sponsors/pubkey" + } + }, + "node_modules/broadcast-channel/node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/browserslist": { "version": "4.28.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", @@ -5345,6 +5384,15 @@ "node": ">=0.10.0" } }, + "node_modules/oblivious-set": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-2.0.0.tgz", + "integrity": "sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -5393,6 +5441,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5425,6 +5482,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6401,6 +6492,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unload": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz", + "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==", + "license": "Apache-2.0", + "funding": { + "url": "https://github.com/sponsors/pubkey" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 10e69a08..fc91c435 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,9 @@ "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", - "@tanstack/react-query": "^5.101.2", - "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/query-broadcast-client-experimental": "5.101.2", + "@tanstack/react-query": "5.101.2", + "@tanstack/react-query-devtools": "5.101.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/src/api/api.ts b/src/api/api.ts index 19c29d81..51897b61 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,14 +3,13 @@ import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; -import { queryKeys } from "@/infra/query-key/query-key"; import queryClient from "@/lib/query/query-client"; -import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass"; +import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; function clearSessionAndRedirectToLogin(): void { clearAuth(); - queryClient.setQueryData(queryKeys.auth.session(), null); + stopQueryBroadcast(queryClient); if (typeof window !== "undefined" && window.location.pathname !== "/login") { window.location.replace("/login"); @@ -48,7 +47,7 @@ export const api: KyInstance = ky.create({ beforeError: [({ error }) => normalizeApiRequestError(error)], afterResponse: [ ({ request, response }) => { - if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) { + if (response.status !== 401 || isAuthLoginRequest(request)) { return response; } diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx new file mode 100644 index 00000000..eadbf15d --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx @@ -0,0 +1,248 @@ +import type { Dispatch, SetStateAction } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material"; +import type { + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; +import { FilterSection } from "./vendor-filters-drawer-section"; + +interface VendorFiltersDrawerFieldsProps { + draft: VendorFilters; + setDraft: Dispatch>; + options: { + companies: VendorFacetCompany[]; + trades: string[]; + locations: VendorFacetLocation[]; + jobBuckets: VendorFacetJobBucket[]; + }; +} + +function CompanyFilterFields({ + draft, + setDraft, + companyOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + companyOptions: VendorFacetCompany[]; +}) { + return ( + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={companyOptions.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilterFields({ + draft, + setDraft, + tradeOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + tradeOptions: string[]; +}) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function LocationFilterFields({ + draft, + setDraft, + locationOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + locationOptions: VendorFacetLocation[]; +}) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={locationOptions.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobBucketFilterFields({ + draft, + setDraft, + jobBucketOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + jobBucketOptions: VendorFacetJobBucket[]; +}) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFiltersDrawerFields({ + draft, + setDraft, + options, +}: VendorFiltersDrawerFieldsProps) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx new file mode 100644 index 00000000..9d7c2997 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Stack, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; + +export function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index e3b91cf5..b18c568c 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import CloseIcon from "@mui/icons-material/Close"; import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; import { VendorFilterOptions } from "./vendor-filter-options"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; -import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -25,12 +25,18 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) setDraft(appliedFilters); + if (open) { + setDraft(appliedFilters); + } }, [open, appliedFilters]); const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + const handleClear = () => { + setDraft(emptyVendorFilters); + }; + const handleApply = () => { onApply(draft); onClose(); @@ -65,7 +71,7 @@ export function VendorFiltersDrawer({ Filters - {activeCount > 0 && } + {Boolean(activeCount > 0) && } @@ -85,12 +91,7 @@ export function VendorFiltersDrawer({ borderTop: (theme) => `1px solid ${theme.palette.divider}`, }} > - + + + ); +} + +function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } { + if (isEdit) { + return { + title: "Edit Vendor Company", + subtitle: "Update company details and technicians in one save.", + }; + } + return { + title: "Add Vendor Company", + subtitle: "Register a company and add zero, one, or multiple technicians.", + }; +} + +function submitLabel(isSaving: boolean, isEdit: boolean): string { + if (isSaving) return "Saving..."; + return isEdit ? "Save" : "Create"; +} + export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { const navigate = useNavigate(); const isEdit = vendorId !== undefined || companyId !== undefined; @@ -59,26 +90,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa } if (isEdit && form.isError) { - return ( - - Failed to load vendor company. Try again. - - - - - ); + return navigate("/vendors")} />; } if (isEdit && !form.roster) { return null; } - const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company"; - const subtitle = isEdit - ? "Update company details and technicians in one save." - : "Register a company and add zero, one, or multiple technicians."; + const { title, subtitle } = rosterPageCopy(isEdit); const companySelectionProps = isEdit ? {} : { @@ -87,6 +106,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa onSelectCompany: form.selectCompany, onClearSelectedCompany: form.clearSelectedCompany, }; + const showPortalToken = Boolean(isEdit && vendorId); return ( @@ -107,7 +127,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa {...companySelectionProps} /> - {isEdit && vendorId !== undefined && vendorId !== "" && ( + {showPortalToken && vendorId !== undefined && ( )} @@ -120,7 +140,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa Cancel diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index ea7ebf00..d3882148 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,5 +1,7 @@ import { useParams } from "react-router"; -import { Alert, Box, CircularProgress } from "@mui/material"; +import { Box } from "@mui/material"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel"; @@ -15,20 +17,17 @@ export default function WorkOrderViewPage() { const workOrderId = id ?? ""; const view = useWorkOrderViewPage(workOrderId); - if (view.isLoading) { - return ( - - - - ); + if (view.isLoading && !view.wo) { + return ; } - if (view.error || !view.wo) { + if (!view.wo) { return ( - - {view.error instanceof Error ? view.error.message : "Work order not found."} - + void view.refetch()} + /> ); } diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx index 86749598..4468592b 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft-actions.tsx @@ -28,6 +28,7 @@ export function CompDocDialogFooterDraftActions({ className="gap-1.5 whitespace-nowrap text-[12px]" sx={COMP_DOC_FOOTER_BTN_SX} style={{ color: "var(--muted-foreground)" }} + disabled={isUploading} onClick={onMarkNotRequired} > Mark as not required @@ -38,6 +39,7 @@ export function CompDocDialogFooterDraftActions({ size="default" className="gap-1.5 whitespace-nowrap text-[12px]" sx={COMP_DOC_FOOTER_BTN_SX} + disabled={isUploading} onClick={onSaveDraft} > Save draft @@ -47,7 +49,7 @@ export function CompDocDialogFooterDraftActions({ size="default" className="gap-1.5 whitespace-nowrap text-[12px]" sx={COMP_DOC_FOOTER_BTN_SX} - disabled={!rowVersion} + disabled={!rowVersion || isUploading} loading={isUploading} loadingText="Uploading…" onClick={() => pdfInputRef.current?.click()} @@ -60,6 +62,7 @@ export function CompDocDialogFooterDraftActions({ className="gap-1.5 whitespace-nowrap text-[12px]" sx={COMP_DOC_FOOTER_BTN_SX} style={{ background: "var(--color-primary)", color: "#fff" }} + disabled={isUploading} onClick={onGenerate} > Generate diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx index 08a9cb20..7a492060 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx @@ -4,13 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; type CompDocDialogHeaderProps = { - info: WorkOrderTableRow; + row: WorkOrderTableRow; cfg: CompletionDocStateConfig; isUploading: boolean; onClose: () => void; }; -export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { +export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { return (
    WO{" "} - #{info.woNumber} + #{row.woNumber} {" · "} - {serviceTitle(info)} + {serviceTitle(row)}
    diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index f87fbb78..030e63df 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -1,15 +1,23 @@ -import type { MouseEvent } from "react"; +import { useRef, useState, type MouseEvent } from "react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { toast } from "react-toastify"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { Text } from "@/components/ui/text"; import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { + formatMediaSizeLimit, + isWorkOrderDocumentsReadOnly, + isWorkOrderFileTooLarge, +} from "@/domain/work-orders/utils/work-order-media-limits"; +import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog"; import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; import { CompDocDialogHeader } from "./comp-doc-dialog-header"; import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links"; import { CompDocPreview } from "./comp-doc-preview"; import { CompDocSidebar } from "./comp-doc-sidebar"; -import { useCompDocDialog } from "./use-comp-doc-dialog"; type CompDocDialogProps = { row: WorkOrderTableRow; @@ -20,6 +28,59 @@ type CompDocDialogProps = { onDateCompletedChange?: (date: string) => void | Promise; }; +type UseCompDocPdfUploadArgs = { + workOrderId: string | number; + rowVersion: string; + onUploaded: () => void | Promise; +}; + +function rejectCompDocPdf(file: File, rowVersion: string): string | null { + if (isWorkOrderFileTooLarge(file)) { + return `File exceeds the ${formatMediaSizeLimit()} limit.`; + } + if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { + return "Please upload a PDF file."; + } + if (!rowVersion) { + return "Missing work order version. Refresh and try again."; + } + return null; +} + +function useCompDocPdfUpload({ workOrderId, rowVersion, onUploaded }: UseCompDocPdfUploadArgs) { + const uploadCompletionMutation = useUploadCompletionDoc(workOrderId); + const pdfInputRef = useRef(null); + const [isFinalizing, setIsFinalizing] = useState(false); + + function handleUploadPdf(files: FileList | null) { + const file = files?.[0]; + if (!file) return; + + const rejection = rejectCompDocPdf(file, rowVersion); + if (rejection) { + toast.error(rejection); + return; + } + + uploadCompletionMutation.mutate( + { file, workOrderVersion: rowVersion }, + { + onSuccess: () => { + // Keep close/actions disabled across the gap between upload settle and docStatus persist. + setIsFinalizing(true); + void Promise.resolve(onUploaded()).finally(() => setIsFinalizing(false)); + }, + }, + ); + } + + return { + pdfInputRef, + handleUploadPdf, + isUploading: uploadCompletionMutation.isPending || isFinalizing, + }; +} + export function CompDocDialog({ row, open, @@ -28,14 +89,33 @@ export function CompDocDialog({ onSave, onDateCompletedChange, }: CompDocDialogProps) { - const dialog = useCompDocDialog({ - row, - open, - data, - onSave, - onDateCompletedChange, + const dialog = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange }); + const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open); + + const templateUrl = boardDetail?.completion.template?.templateUrl; + const signOffAttachment = boardDetail?.completion.signOffAttachment || null; + const rowVersion = boardDetail?.info.rowVersion || row.rowVersion; + const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status); + + // Uploading a signed PDF finalizes through the same Generate flow, so the + // completedDate/address prerequisites still persist before docStatus Yes. + const upload = useCompDocPdfUpload({ + workOrderId: row.id, + rowVersion, + onUploaded: dialog.handleGenerate, }); - const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false); + + // A signed PDF on file means the document exists even if docState has not caught up. + const isGenerated = dialog.isGenerated || Boolean(signOffAttachment); + const handleClose = upload.isUploading ? undefined : () => onOpenChange(false); + + function handleDownloadPdf() { + if (signOffAttachment) { + window.open(signOffAttachment, "_blank", "noopener,noreferrer"); + return; + } + dialog.handleDownloadPdf(); + } return ( - - Completion Document — WO #{dialog.info.woNumber} - + Completion Document — WO #{row.woNumber} Generate, save or mark not-required the completion document for this work order.
    handleClose?.()} />
    @@ -112,18 +192,18 @@ export function CompDocDialog({
    diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx index df26f1a8..64e167bc 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-preview.tsx @@ -1,8 +1,10 @@ import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { CompDocPreviewDocument } from "./comp-doc-preview-document"; -import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp"; -import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge"; +import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp"; +import { CompDocPreviewBadge } from "./comp-doc-preview-badge"; +import { CompDocPreviewHeader } from "./comp-doc-preview-header"; +import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure"; +import { CompDocPreviewSummary } from "./comp-doc-preview-summary"; type CompDocPreviewProps = { row: WorkOrderTableRow; @@ -30,7 +32,7 @@ export function CompDocPreview({ background: "var(--color-bg-muted)", }} > - +
    - +
    +
    +
    + + + +
    +
    +
    - {isNotRequired && } + {isNotRequired && }
    diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx index 5d9de8b9..a16fbbbe 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-list.tsx @@ -7,26 +7,29 @@ type CompDocProcedureListProps = { export function CompDocProcedureList({ proc }: CompDocProcedureListProps) { return (
      - {proc.steps.map((st, i) => ( -
    1. - {st.text} - {st.sub != null && ( -
        - {st.sub.map((x, j) => ( -
      1. - {x} -
      2. - ))} -
      - )} -
    2. - ))} + {proc.steps.map((st, i) => { + const subSteps = st.sub ?? []; + return ( +
    3. + {st.text} + {subSteps.length > 0 && ( +
        + {subSteps.map((x, j) => ( +
      1. + {x} +
      2. + ))} +
      + )} +
    4. + ); + })}
    ); } diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx index 02035224..a48499c4 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx @@ -1,12 +1,8 @@ -import { Building2, MapPin, Users, Wrench } from "lucide-react"; import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { - DOC_STATE_CFG, - docStatusCardDescription, -} from "@/domain/work-orders/utils/completion-doc-state"; -import { CompDocDetailRow } from "./comp-doc-detail-row"; -import { CompDocEditRow } from "./comp-doc-edit-row"; +import { CompDocDetailsSection } from "./comp-doc-details-section"; +import { CompDocNotesField } from "./comp-doc-notes-field"; +import { CompDocStatusCard } from "./comp-doc-status-card"; type CompDocSidebarProps = { row: WorkOrderTableRow; @@ -14,6 +10,8 @@ type CompDocSidebarProps = { docState: DocState; generatedAt: string; notes: string; + editAddress: string; + onAddressChange: (value: string) => void; editDateCompleted: string; amazonRep: string; seaRep: string; @@ -28,6 +26,8 @@ export function CompDocSidebar({ docState, generatedAt, notes, + editAddress, + onAddressChange, editDateCompleted, amazonRep, seaRep, @@ -35,139 +35,25 @@ export function CompDocSidebar({ onNotesChange, onDateCompletedChange, }: CompDocSidebarProps) { - const displayState: DocState = - isGenerated && docState !== "not-required" ? "generated" : docState; - const cfg = DOC_STATE_CFG[displayState]; - const StateIcon = cfg.icon; - return (
    -
    -
    - -
    -
    -
    - {cfg.cardTitle} -
    -
    - {docStatusCardDescription(displayState, generatedAt)} -
    -
    -
    - -
    -
    - Work Order Details -
    -
    - - - - - - -
    -
    - -
    - -