diff --git a/docs/adr/0001-query-broadcast-client.md b/docs/adr/0001-query-broadcast-client.md new file mode 100644 index 00000000..4ced20f2 --- /dev/null +++ b/docs/adr/0001-query-broadcast-client.md @@ -0,0 +1,81 @@ +# 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: + +- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a + single named channel (`seahaven-admin-query`). +- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE +=== "test"`), so it never runs in unit tests or node-based tooling. +- Is invoked once from `src/lib/query/query-client.ts` against the app's + singleton `QueryClient`, so every tab that loads the SPA subscribes to the + same channel automatically — no per-feature wiring required. + +This piggybacks on the query cache we already have (no parallel store), uses +the library that owns the `QueryClient` we already depend on, and requires +close to zero application code (~20 lines) to adopt. + +### 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. +- **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 reflect mutations (status changes, dispatch + actions, comments, patches) without a manual refresh; the integration is + isolated to one setup file and does not touch domain/query-key code. +- **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 `setupQueryBroadcast` 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. This is + acceptable for the current requirement (single user, multiple tabs). +- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental` + dependency from `package.json` and delete the call to `setupQueryBroadcast` + in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module + itself). No other code depends on it, so rollback is a single, isolated + change with no data migration. diff --git a/package-lock.json b/package-lock.json index c732e572..bd4d8c90 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 3df2f4da..e3204f6b 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,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/scripts/governance-baseline.json b/scripts/governance-baseline.json index 401e1e70..18c4ae08 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -3,11 +3,6 @@ "purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.", "maxFileLines": 500, "godfileDebt": [ - { - "path": "src/app/(protected)/workorders/[id].tsx", - "maxLines": 797, - "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components." - }, { "path": "src/app/(protected)/vendors/index.tsx", "maxLines": 612, diff --git a/src/api/api.ts b/src/api/api.ts index 19c29d81..d27d3365 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -5,7 +5,6 @@ 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 { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; function clearSessionAndRedirectToLogin(): void { @@ -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)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index cfdcf7d7..945bd3b3 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,108 +1,110 @@ -import { useState } from "react"; import { useNavigate, 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 "@/app/(protected)/workorders/_components/loading/detail-page-skeleton"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; -import { useVendorEditor } from "./_components/use-vendor-editor"; -import { WorkOrderActivity } from "./_components/work-order-activity"; +import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section"; +import { WorkOrderDetailsPanel } from "@/app/(protected)/workorders/_components/view/work-order-details-panel"; import { - DispatchAccordion, - WorkOrderDetailsSection, - WorkOrderHeader, - WorkOrderSummaryAccordions, -} from "./_components/work-order-view-sections"; -import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { - useChangeWorkOrderAssignment, - useChangeWorkOrderStatus, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; + WorkOrderDescriptionAccordion, + WorkOrderInfoAccordions, +} from "@/app/(protected)/workorders/_components/view/work-order-info-accordions"; +import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_components/view/work-order-dispatches-accordion"; +import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel"; +import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog"; +import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller"; export default function WorkOrderViewPage() { const { id } = useParams(); const navigate = useNavigate(); const workOrderId = id ?? ""; + const controller = useWorkOrderViewController(workOrderId); + const { wo, isLoading, error, refetch, vendorEditor } = controller; - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); - const { data: users = [] } = useWorkOrderUsers(); - const statusMutation = useChangeWorkOrderStatus(workOrderId); - const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); - const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() }); + if (isLoading && !wo) { + return ; + } - const [dispatchOpen, setDispatchOpen] = useState(false); - const [dispatchDetailId, setDispatchDetailId] = useState(null); - - if (isLoading) { + if ((error || !wo) && !isLoading) { return ( - - + + void refetch()} + /> ); } - if (error || !wo) { - return ( - - - {error instanceof Error ? error.message : "Work order not found."} - - - ); + if (!wo) { + return ; } return ( - statusMutation.mutate(status)} - onDispatch={() => setDispatchOpen(true)} + statusAnchor={controller.statusAnchor} + onStatusAnchorChange={controller.setStatusAnchor} + onStatusChange={(status) => controller.statusMutation.mutate(status)} + onDispatch={() => controller.setDispatchOpen(true)} onEdit={() => navigate(`/workorders/edit/${id}`)} onBack={() => navigate("/workorders")} /> - assignmentMutation.mutate(payload)} + users={controller.users} + assignAnchor={controller.assignAnchor} + onAssignAnchorChange={controller.setAssignAnchor} + onAssign={(userId, userName) => controller.assignmentMutation.mutate({ userId, userName })} /> - - - void vendorEditor.open(event, vendorId, vendorName) - } - onViewDispatch={setDispatchDetailId} + + { + event.stopPropagation(); + void vendorEditor.openVendorEditor(vendorId, vendorName); + }} /> - + - {dispatchOpen && ( + {controller.dispatchOpen && ( setDispatchOpen(false)} + onClose={() => controller.setDispatchOpen(false)} onDispatched={() => void refetch()} /> )} - {dispatchDetailId != null && ( + {controller.dispatchDetailId != null && ( setDispatchDetailId(null)} + onClose={() => controller.setDispatchDetailId(null)} onUpdated={() => void refetch()} /> )} - + vendorEditor.setVendorEditor((current) => (current ? { ...current, ...patch } : current)) + } + onSave={vendorEditor.saveVendorEditor} /> ); 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/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index ec9c3479..829cf976 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -1,7 +1,7 @@ import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row"; import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label"; -import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -14,14 +14,12 @@ type SlideOverInfoTabDisplayViewProps = { source: WorkOrderTableRow; isCancelled: boolean; dispatcher?: WorkOrderUser; - vendors: VendorDropdownItem[]; }; export function SlideOverInfoTabDisplayView({ source, isCancelled, dispatcher, - vendors, }: SlideOverInfoTabDisplayViewProps) { const appt = parseApptTime(source.apptTime); const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—"; @@ -73,7 +71,7 @@ export function SlideOverInfoTabDisplayView({ )} - + diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx index 389ff87e..6c66e829 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-vendor-field.tsx @@ -1,45 +1,25 @@ -import { useState } from "react"; -import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog"; -import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; type SlideOverInfoTabVendorFieldProps = { source: WorkOrderTableRow; - vendors: VendorDropdownItem[]; }; -export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) { - const [vendorDlg, setVendorDlg] = useState(false); - +/** Display-only vendor summary. Assignment edits belong in edit mode (VendorCell). */ +export function SlideOverInfoTabVendorField({ source }: SlideOverInfoTabVendorFieldProps) { if (!source.tech && !source.company) { return —; } return ( - <> - - setVendorDlg(false)} - /> - + )} + ); } diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx index c8d9e09b..5d109b07 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx @@ -58,7 +58,6 @@ export function SlideOverInfoTab({ source={source} isCancelled={isCancelled} dispatcher={dispatcher} - vendors={vendors} /> ); } diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx deleted file mode 100644 index b7ab376c..00000000 --- a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { useMemo, useState } from "react"; -import { - Box, - Button, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - IconButton, - MenuItem, - Radio, - Stack, - TextField, - Typography, -} from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; -import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { - useTaskListTemplates, - useWorkOrderVendors, -} from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; - -interface SelectedWorkOrder { - id: string | number; - number: string; - title: string; -} - -interface DispatchCreateModalProps { - workOrder: WorkOrderDetail; - onClose: () => void; - onDispatched: () => void; -} - -export function DispatchCreateModal({ - workOrder, - onClose, - onDispatched, -}: DispatchCreateModalProps) { - const [selectedVendorId, setSelectedVendorId] = useState(null); - const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || ""); - const [scheduledDate, setScheduledDate] = useState(""); - const [description, setDescription] = useState(workOrder.description || ""); - const [vendorSearch, setVendorSearch] = useState(""); - const [selectedTemplateId, setSelectedTemplateId] = useState(""); - const [customItems, setCustomItems] = useState([]); - const [newCustomItem, setNewCustomItem] = useState(""); - const [selectedWOs, setSelectedWOs] = useState([ - { - id: workOrder.id, - number: workOrder.internalWONumber, - title: workOrder.title, - }, - ]); - const [woSearch, setWoSearch] = useState(""); - - const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors( - workOrder.trade, - workOrder.locationZip, - ); - const { data: taskTemplates = [] } = useTaskListTemplates(); - const dispatchMutation = useDispatchToVendor(workOrder.id); - - const { data: siteWorkOrdersData } = useWorkOrdersList({ - search: "", - page: 1, - pageSize: 500, - sortBy: "number", - sortDir: "asc", - locationId: workOrder.locationId, - }); - - const siteWorkOrders = useMemo( - () => - (siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)), - [siteWorkOrdersData?.items, workOrder.id], - ); - - const filteredVendors = useMemo( - () => - vendors.filter( - (vendor) => - vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) || - vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()), - ), - [vendors, vendorSearch], - ); - - const filteredSiteWOs = useMemo( - () => - siteWorkOrders - .filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id))) - .filter( - (item) => - !woSearch || - item.woNumber.includes(woSearch) || - item.title.toLowerCase().includes(woSearch.toLowerCase()), - ) - .slice(0, 10), - [siteWorkOrders, selectedWOs, woSearch], - ); - - const handleSubmit = () => { - if (!selectedVendorId) { - return; - } - - dispatchMutation.mutate( - { - workOrderIds: selectedWOs.map((wo) => wo.id), - vendorIds: [selectedVendorId], - nteAmount: nteAmount ? parseFloat(nteAmount) : null, - scheduledDate: scheduledDate || null, - description, - taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null, - customChecklistItems: customItems.length > 0 ? customItems : null, - }, - { - onSuccess: () => { - onDispatched(); - onClose(); - }, - }, - ); - }; - - return ( - - - Dispatch{" "} - {selectedWOs.length > 1 - ? `${selectedWOs.length} Work Orders` - : `Work Order ${workOrder.internalWONumber}`} - - - - - - - - Work Orders ({selectedWOs.length}) - - - {selectedWOs.map((wo) => ( - setSelectedWOs((prev) => prev.filter((item) => item.id !== wo.id)) - : undefined - } - /> - ))} - - {siteWorkOrders.length > 0 && ( - - setWoSearch(event.target.value)} - placeholder="Search to filter..." - fullWidth - className="mb-2" - /> - - {filteredSiteWOs.map((item) => ( - { - setSelectedWOs((prev) => [ - ...prev, - { id: item.id, number: item.woNumber, title: item.title }, - ]); - setWoSearch(""); - }} - > - - {item.woNumber} — {item.title} - - - ))} - - - )} - - - - - Select Vendor - - setVendorSearch(event.target.value)} - placeholder="Search vendors..." - fullWidth - className="mb-2" - /> - {isLoadingVendors ? ( - - Loading vendors... - - ) : filteredVendors.length === 0 ? ( - - No vendors found - - ) : ( - - {filteredVendors.map((vendor) => ( - - setSelectedVendorId((current) => (current === vendor.id ? null : vendor.id)) - } - > - - - - {vendor.companyName} - - {Boolean(vendor.tradeSpecialties) && ( - - {vendor.tradeSpecialties} - - )} - {Boolean(vendor.address) && ( - - {vendor.address} - - )} - - {vendor.distanceMiles != null && ( - - {vendor.distanceMiles} miles - - )} - - ))} - - )} - - - - setNteAmount(event.target.value)} - fullWidth - slotProps={{ - htmlInput: { step: "0.01", min: 0 }, - }} - /> - setScheduledDate(event.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - fullWidth - /> - - - setSelectedTemplateId(event.target.value)} - fullWidth - > - No checklist template - {taskTemplates.map((template) => ( - - {template.name} ({template.items?.length ?? 0} items) - - ))} - - - {customItems.length > 0 && ( - - {customItems.map((item, index) => ( - setCustomItems((prev) => prev.filter((_, i) => i !== index))} - /> - ))} - - )} - - setNewCustomItem(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && newCustomItem.trim()) { - event.preventDefault(); - setCustomItems((prev) => [...prev, newCustomItem.trim()]); - setNewCustomItem(""); - } - }} - placeholder="Add custom checklist item..." - fullWidth - size="small" - /> - - setDescription(event.target.value)} - fullWidth - /> - - - - - - - ); -} - -function PaperList({ children }: { children: React.ReactNode }) { - return ( - {children} - ); -} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx new file mode 100644 index 00000000..8b9aa5e8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-checklist-fields.tsx @@ -0,0 +1,84 @@ +import { Chip, MenuItem, Skeleton, Stack, TextField } from "@mui/material"; +import type { TaskListTemplate } from "@/domain/work-orders/types/work-order"; + +interface DispatchCreateChecklistFieldsProps { + taskTemplates: TaskListTemplate[]; + isLoadingTemplates: boolean; + selectedTemplateId: string; + onTemplateChange: (id: string) => void; + isPending: boolean; + customItems: string[]; + onRemoveCustomItem: (index: number) => void; + newCustomItem: string; + onNewCustomItemChange: (value: string) => void; + onAddCustomItem: () => void; +} + +export function DispatchCreateChecklistFields({ + taskTemplates, + isLoadingTemplates, + selectedTemplateId, + onTemplateChange, + isPending, + customItems, + onRemoveCustomItem, + newCustomItem, + onNewCustomItemChange, + onAddCustomItem, +}: DispatchCreateChecklistFieldsProps) { + return ( + <> + {isLoadingTemplates ? ( + + ) : ( + onTemplateChange(event.target.value)} + fullWidth + disabled={isPending} + > + No checklist template + {taskTemplates.map((template) => ( + + {template.name} ({template.items?.length ?? 0} items) + + ))} + + )} + + {customItems.length > 0 && ( + + {customItems.map((item, index) => ( + onRemoveCustomItem(index)} + /> + ))} + + )} + + onNewCustomItemChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && newCustomItem.trim()) { + event.preventDefault(); + onAddCustomItem(); + } + }} + placeholder="Add custom checklist item..." + fullWidth + size="small" + /> + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx new file mode 100644 index 00000000..fa265c4f --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-details-fields.tsx @@ -0,0 +1,38 @@ +import { Stack, TextField } from "@mui/material"; + +interface DispatchCreateDetailsFieldsProps { + nteAmount: string; + onNteAmountChange: (value: string) => void; + scheduledDate: string; + onScheduledDateChange: (value: string) => void; +} + +export function DispatchCreateDetailsFields({ + nteAmount, + onNteAmountChange, + scheduledDate, + onScheduledDateChange, +}: DispatchCreateDetailsFieldsProps) { + return ( + + onNteAmountChange(event.target.value)} + fullWidth + slotProps={{ + htmlInput: { step: "0.01", min: 0 }, + }} + /> + onScheduledDateChange(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx new file mode 100644 index 00000000..29133854 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-vendor-section.tsx @@ -0,0 +1,110 @@ +import { Box, Radio, Skeleton, Stack, TextField, Typography } from "@mui/material"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; + +interface DispatchCreateVendorSectionProps { + vendors: VendorDropdownItem[]; + isLoading: boolean; + vendorSearch: string; + selectedVendorId: string | number | null; + onSearchChange: (value: string) => void; + onSelect: (vendorId: string | number) => void; +} + +function VendorLine({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function VendorOption({ + vendor, + isSelected, + onSelect, +}: { + vendor: VendorDropdownItem; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + + + + {vendor.companyName} + + {Boolean(vendor.contactName) && } + {Boolean(vendor.tradeSpecialties) && } + {Boolean(vendor.address) && } + + {vendor.distanceMiles != null && ( + + {vendor.distanceMiles} miles + + )} + + ); +} + +export function DispatchCreateVendorSection({ + vendors, + isLoading, + vendorSearch, + selectedVendorId, + onSearchChange, + onSelect, +}: DispatchCreateVendorSectionProps) { + return ( + + + Select Vendor + + onSearchChange(event.target.value)} + placeholder="Search vendors..." + fullWidth + className="mb-2" + /> + {isLoading ? ( + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + ) : vendors.length === 0 ? ( + + No vendors found + + ) : ( + + {vendors.map((vendor) => ( + onSelect(vendor.id)} + /> + ))} + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx new file mode 100644 index 00000000..c3026a26 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/dispatch-create-work-orders-section.tsx @@ -0,0 +1,73 @@ +import { Box, Chip, Stack, TextField, Typography } from "@mui/material"; +import type { SelectedWorkOrderRef } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +interface DispatchCreateWorkOrdersSectionProps { + workOrderId: string | number; + selectedWOs: SelectedWorkOrderRef[]; + siteWorkOrders: WorkOrderTableRow[]; + filteredSiteWOs: WorkOrderTableRow[]; + woSearch: string; + onWoSearchChange: (value: string) => void; + onAddWO: (item: WorkOrderTableRow) => void; + onRemoveWO: (id: string | number) => void; +} + +export function DispatchCreateWorkOrdersSection({ + workOrderId, + selectedWOs, + siteWorkOrders, + filteredSiteWOs, + woSearch, + onWoSearchChange, + onAddWO, + onRemoveWO, +}: DispatchCreateWorkOrdersSectionProps) { + return ( + + + Work Orders ({selectedWOs.length}) + + + {selectedWOs.map((wo) => ( + onRemoveWO(wo.id) : undefined} + /> + ))} + + {siteWorkOrders.length > 0 && ( + + onWoSearchChange(event.target.value)} + placeholder="Search to filter..." + fullWidth + className="mb-2" + /> + + {filteredSiteWOs.map((item) => ( + onAddWO(item)} + > + + {item.woNumber} — {item.title} + + + ))} + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx new file mode 100644 index 00000000..08fe407d --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch-create-modal/index.tsx @@ -0,0 +1,114 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + TextField, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { Button } from "@/components/ui/button"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; +import { useDispatchCreateController } from "@/app/(protected)/workorders/_hooks/use-dispatch-create-controller"; +import { DispatchCreateWorkOrdersSection } from "./dispatch-create-work-orders-section"; +import { DispatchCreateVendorSection } from "./dispatch-create-vendor-section"; +import { DispatchCreateChecklistFields } from "./dispatch-create-checklist-fields"; +import { DispatchCreateDetailsFields } from "./dispatch-create-details-fields"; + +interface DispatchCreateModalProps { + workOrder: WorkOrderDetail; + onClose: () => void; + onDispatched: () => void; +} + +export function DispatchCreateModal({ + workOrder, + onClose, + onDispatched, +}: DispatchCreateModalProps) { + const controller = useDispatchCreateController(workOrder, onClose, onDispatched); + + return ( + + + Dispatch{" "} + {controller.selectedWOs.length > 1 + ? `${controller.selectedWOs.length} Work Orders` + : `Work Order ${workOrder.internalWONumber}`} + + + + + + + + + controller.setSelectedVendorId((current) => (current === vendorId ? null : vendorId)) + } + /> + + + + + + controller.setDescription(event.target.value)} + fullWidth + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx index 03ff527a..0f54f805 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx @@ -6,6 +6,7 @@ type DispatchChecklistSectionProps = { items: DispatchChecklistItem[]; isLocked: boolean; isUpdating: boolean; + isAdding?: boolean; newItem: string; onNewItemChange: (value: string) => void; onToggleItem: (itemId: string | number, isCompleted: boolean) => void; @@ -16,6 +17,7 @@ export function DispatchChecklistSection({ items, isLocked, isUpdating, + isAdding = false, newItem, onNewItemChange, onToggleItem, @@ -58,14 +60,16 @@ export function DispatchChecklistSection({ value={newItem} onChange={(event) => onNewItemChange(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter" && newItem.trim()) { + if (event.key === "Enter" && newItem.trim() && !isAdding) { event.preventDefault(); onAddItem(newItem.trim()); } }} - placeholder="Add checklist item..." + placeholder={isAdding ? "Adding…" : "Add checklist item..."} + disabled={isAdding} fullWidth className="mt-2" + slotProps={{ htmlInput: { "aria-busy": isAdding || undefined } }} /> )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx new file mode 100644 index 00000000..637f3a47 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-body.tsx @@ -0,0 +1,100 @@ +import { DialogContent, Skeleton } from "@mui/material"; +import type { DispatchDetail } from "@/domain/work-orders/types/work-order"; +import type { DispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller"; +import { DispatchChecklistSection } from "./dispatch-checklist-section"; +import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form"; +import { DispatchSignoffsSection } from "./dispatch-signoffs-section"; +import { DispatchUpliftsSection } from "./dispatch-uplifts-section"; +import { DispatchVendorCommunication } from "./dispatch-vendor-communication"; +import { DispatchVerifyFooter } from "./dispatch-verify-footer"; + +interface DispatchDetailBodyProps { + dispatch: DispatchDetail; + controller: DispatchDetailController; +} + +export function DispatchDetailBody({ dispatch, controller }: DispatchDetailBodyProps) { + return ( + + + {controller.editing ? ( + controller.setEditing(false)} + isSaving={controller.updateDispatchMutation.isPending} + /> + ) : ( + controller.handleCancelDispatch(dispatch)} + /> + )} + + + {(dispatch.checklistItems.length > 0 || !controller.isLocked) && ( + + controller.updateChecklistMutation.mutate({ + itemId, + isCompleted, + completedBy: controller.userName, + }) + } + onAddItem={(text) => + controller.addChecklistMutation.mutate(text, { + onSuccess: () => controller.setNewChecklistItem(""), + }) + } + /> + )} + + + + {controller.isLoadingUplifts ? ( + + ) : ( + controller.setUpliftAction({ type: "approve", uplift })} + onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} + /> + )} + + + + controller.verifyMutation.mutate(undefined)} + /> + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx index 1a0a27f2..152eb807 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx @@ -1,5 +1,6 @@ -import { Box, Button, MenuItem, Stack, TextField } from "@mui/material"; +import { Box, MenuItem, Stack, TextField } from "@mui/material"; import { DetailField } from "@/components/common/detail-field"; +import { Button } from "@/components/ui/button"; import { DISPATCH_STATUS_OPTIONS, type DispatchDetail, @@ -84,10 +85,12 @@ export function DispatchEditForm({ onChange={(event) => onChange({ ...form, description: event.target.value })} /> - + - ); @@ -138,12 +141,10 @@ export function DispatchDetailsView({ )} {!isLocked && ( - - + )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx index 00c5e177..c2526620 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx @@ -5,12 +5,14 @@ import { formatDateTime } from "@/lib/time-utils"; type DispatchSignoffsSectionProps = { signoffs: DispatchSignoff[]; isLocked: boolean; + isSigningOff?: boolean; onCollectSignature: (type: "Customer" | "Vendor") => void; }; export function DispatchSignoffsSection({ signoffs, isLocked, + isSigningOff = false, onCollectSignature, }: DispatchSignoffsSectionProps) { return ( @@ -49,9 +51,9 @@ export function DispatchSignoffsSection({ variant="outlined" className="mt-2" onClick={() => onCollectSignature(type)} - disabled={isLocked} + disabled={isLocked || isSigningOff} > - {isLocked ? "Not signed" : "Collect Signature"} + {isLocked ? "Not signed" : isSigningOff ? "Saving…" : "Collect Signature"} )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx index 74cf4e1a..816b78a5 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx @@ -1,6 +1,7 @@ -import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; +import { Box, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material"; import { CommentTimeline } from "@/components/common/comment-timeline"; import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; +import { Button } from "@/components/ui/button"; import type { DispatchComment } from "@/domain/work-orders/types/work-order"; type DispatchVendorCommunicationProps = { @@ -61,10 +62,11 @@ export function DispatchVendorCommunication({ /> diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx index 7ee7cb1e..4c8cb8a7 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx @@ -1,4 +1,5 @@ -import { Alert, Button, Stack, Typography } from "@mui/material"; +import { Alert, Stack, Typography } from "@mui/material"; +import { Button } from "@/components/ui/button"; import { formatDateTime } from "@/lib/time-utils"; type DispatchVerifyFooterProps = { @@ -34,10 +35,15 @@ export function DispatchVerifyFooter({ )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx index 7189fe1e..b5d0562c 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -1,13 +1,10 @@ -import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { Dialog, DialogTitle } from "@mui/material"; import { SignatureCapture } from "@/components/common/signature-capture"; import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller"; -import { DispatchChecklistSection } from "./dispatch-checklist-section"; +import { DispatchDetailSkeleton } from "@/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton"; +import { guardDialogClose } from "@/lib/dialog-guard"; +import { DispatchDetailBody } from "./dispatch-detail-body"; import { DispatchDetailHeader } from "./dispatch-detail-header"; -import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form"; -import { DispatchSignoffsSection } from "./dispatch-signoffs-section"; -import { DispatchUpliftsSection } from "./dispatch-uplifts-section"; -import { DispatchVendorCommunication } from "./dispatch-vendor-communication"; -import { DispatchVerifyFooter } from "./dispatch-verify-footer"; import { UpliftDecisionDialog } from "./uplift-decision-dialog"; interface DispatchDetailModalProps { @@ -23,13 +20,12 @@ export function DispatchDetailModal({ onClose, }: DispatchDetailModalProps) { const controller = useDispatchDetailController(dispatchId, workOrderId); + const handleClose = guardDialogClose(controller.isMutating, onClose); - if (controller.isLoading) { + if (controller.isLoading && !controller.dispatch) { return ( - - - + ); } @@ -42,91 +38,23 @@ export function DispatchDetailModal({ return ( <> - + - + { + if (!controller.isMutating) onClose(); + }} + /> - - - {controller.editing ? ( - controller.setEditing(false)} - isSaving={controller.updateDispatchMutation.isPending} - /> - ) : ( - controller.handleCancelDispatch(dispatch)} - /> - )} - - - {(dispatch.checklistItems.length > 0 || !controller.isLocked) && ( - - controller.updateChecklistMutation.mutate({ - itemId, - isCompleted, - completedBy: controller.userName, - }) - } - onAddItem={(text) => - controller.addChecklistMutation.mutate(text, { - onSuccess: () => controller.setNewChecklistItem(""), - }) - } - /> - )} - - - - controller.setUpliftAction({ type: "approve", uplift })} - onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} - /> - - - - controller.verifyMutation.mutate(undefined)} - /> - + {controller.sigCaptureType != null && ( controller.setSigCaptureType(null)} onSave={(signature, method, name) => { controller.addSignoffMutation.mutate( @@ -148,7 +76,14 @@ export function DispatchDetailModal({ type={controller.upliftAction.type} requestedNTE={controller.upliftAction.uplift.requestedNTE} currentNTE={controller.upliftAction.uplift.currentNTE} - onClose={() => controller.setUpliftAction(null)} + onClose={() => { + if ( + !controller.approveUpliftMutation.isPending && + !controller.denyUpliftMutation.isPending + ) { + controller.setUpliftAction(null); + } + }} onConfirm={controller.handleUpliftConfirm} isPending={ controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx index c8e7cf2f..e3c89564 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { - Button, Dialog, DialogActions, DialogContent, @@ -8,6 +7,8 @@ import { TextField, Typography, } from "@mui/material"; +import { Button } from "@/components/ui/button"; +import { guardDialogClose } from "@/lib/dialog-guard"; type UpliftDecisionDialogProps = { open: boolean; @@ -31,17 +32,17 @@ export function UpliftDecisionDialog({ const [note, setNote] = useState(""); const handleClose = () => { + if (isPending) return; setNote(""); onClose(); }; const handleConfirm = () => { onConfirm(note.trim()); - setNote(""); }; return ( - + {type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"} @@ -60,14 +61,18 @@ export function UpliftDecisionDialog({ label={type === "deny" ? "Denial reason (required)" : "Optional note"} value={note} onChange={(event) => setNote(event.target.value)} + disabled={isPending} /> - + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx b/src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx new file mode 100644 index 00000000..55186842 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-details-panel.tsx @@ -0,0 +1,76 @@ +import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { Field } from "@/app/(protected)/workorders/_components/view/work-order-timelines"; + +interface WorkOrderDetailsPanelProps { + wo: WorkOrderDetail; + users: WorkOrderUser[]; + assignAnchor: HTMLElement | null; + onAssignAnchorChange: (anchor: HTMLElement | null) => void; + onAssign: (userId: string | number, userName: string) => void; +} + +export function WorkOrderDetailsPanel({ + wo, + users, + assignAnchor, + onAssignAnchorChange, + onAssign, +}: WorkOrderDetailsPanelProps) { + return ( + + + Details + + + + + + + + + Assigned To + + + onAssignAnchorChange(null)} + > + { + onAssignAnchorChange(null); + onAssign("", ""); + }} + > + Unassigned + + {users.map((user) => ( + { + onAssignAnchorChange(null); + onAssign(user.id, user.name); + }} + > + {user.name} + + ))} + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx new file mode 100644 index 00000000..871d7120 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx @@ -0,0 +1,117 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { WorkOrderDispatchSummary } from "@/domain/work-orders/types/work-order"; +import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; + +interface WorkOrderDispatchesAccordionProps { + dispatches: WorkOrderDispatchSummary[]; + onSelectDispatch: (dispatchId: string | number) => void; + onEditVendor: (event: React.MouseEvent, vendorId: string | number, vendorName: string) => void; +} + +function DispatchWaitCell({ dispatch }: { dispatch: WorkOrderDispatchSummary }) { + if (dispatch.status !== "Sent") { + return <>—; + } + + const waitColor = waitTimeColor(dispatch.dispatchedAt); + const colorClass = + waitColor === "green" + ? "text-green-600" + : waitColor === "yellow" + ? "text-yellow-600" + : "text-red-600"; + + return ( + + {timeSince(dispatch.dispatchedAt)} + + ); +} + +export function WorkOrderDispatchesAccordion({ + dispatches, + onSelectDispatch, + onEditVendor, +}: WorkOrderDispatchesAccordionProps) { + return ( + + }> + Dispatches ({dispatches.length}) + + + {dispatches.length ? ( + + + + + Dispatch # + Vendor + NTE + Status + Date + Wait + Actions + + + + {dispatches.map((dispatch) => ( + onSelectDispatch(dispatch.id)} + > + {dispatch.dispatchNumber || dispatch.poNumber} + {dispatch.vendorName} + + {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} + + {dispatch.status} + + {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} + + + + + + + + + ))} + +
+
+ ) : ( + + No dispatches yet. + + )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-header-section.tsx b/src/app/(protected)/workorders/_components/view/work-order-header-section.tsx new file mode 100644 index 00000000..0fa4ede7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-header-section.tsx @@ -0,0 +1,113 @@ +import { Link as RouterLink } from "react-router"; +import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { + WORK_ORDER_STATUS_OPTIONS, + type WorkOrderStatus, +} from "@/domain/work-orders/types/work-order"; + +const STATUS_COLORS: Record< + string, + "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" +> = { + Open: "primary", + "In Progress": "info", + "On Hold": "warning", + Done: "success", + Cancelled: "default", +}; + +interface WorkOrderHeaderSectionProps { + wo: WorkOrderDetail; + statusAnchor: HTMLElement | null; + onStatusAnchorChange: (anchor: HTMLElement | null) => void; + onStatusChange: (status: WorkOrderStatus) => void; + onDispatch: () => void; + onEdit: () => void; + onBack: () => void; +} + +export function WorkOrderHeaderSection({ + wo, + statusAnchor, + onStatusAnchorChange, + onStatusChange, + onDispatch, + onEdit, + onBack, +}: WorkOrderHeaderSectionProps) { + return ( + <> + + + Work Orders + + + {wo.internalWONumber} + + + + + + {wo.title} + + onStatusAnchorChange(event.currentTarget)} + className="w-fit cursor-pointer" + /> + onStatusAnchorChange(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + { + onStatusAnchorChange(null); + if (status !== wo.status) { + onStatusChange(status); + } + }} + > + {status} + + ))} + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx b/src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx new file mode 100644 index 00000000..2fce76a7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-info-accordions.tsx @@ -0,0 +1,68 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { formatDateTime } from "@/lib/time-utils"; +import { Field } from "@/app/(protected)/workorders/_components/view/work-order-timelines"; + +export function WorkOrderInfoAccordions({ wo }: { wo: WorkOrderDetail }) { + return ( + <> + + }> + Classification + + + + + + + + + + + }> + Schedule + + + + + + + + + + + + }> + Financial + + + + + + + + + + + ); +} + +export function WorkOrderDescriptionAccordion({ description }: { description: string }) { + return ( + + }> + Description + + + {description || "No description provided."} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-timelines.tsx b/src/app/(protected)/workorders/_components/view/work-order-timelines.tsx new file mode 100644 index 00000000..3ce629fb --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-timelines.tsx @@ -0,0 +1,125 @@ +import { useMemo } from "react"; +import { Box, Stack, Typography } from "@mui/material"; +import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order"; +import { formatDateTime } from "@/lib/time-utils"; + +export function Field({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} + +export function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) { + const sorted = useMemo( + () => + [...comments].sort( + (a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(), + ), + [comments], + ); + + if (!sorted.length) { + return ( + + No comments yet. + + ); + } + + return ( + + {sorted.map((comment) => ( + + + + {comment.userName || comment.commenter || "System"} + + + {comment.createdDate ? formatDateTime(comment.createdDate) : ""} + + + {comment.commenttext} + + ))} + + ); +} + +export function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) { + const sorted = useMemo( + () => + [...entries].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ), + [entries], + ); + + if (!sorted.length) { + return ( + + No changes recorded. + + ); + } + + return ( + + {sorted.map((entry) => ( + + + + {entry.userName || "System"} + + + {entry.createdAt ? formatDateTime(entry.createdAt) : ""} + + + + {entry.fieldName} {entry.oldValue} → {entry.newValue} + + + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx new file mode 100644 index 00000000..085db00c --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx @@ -0,0 +1,101 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { + formatPhoneMask, + isVendorEditorValid, + type VendorEditorState, +} from "@/app/(protected)/workorders/_hooks/use-vendor-editor"; + +interface WorkOrderVendorEditorDialogProps { + vendorEditor: VendorEditorState | null; + isLoading: boolean; + isSaving: boolean; + error: string; + onClose: () => void; + onChange: (patch: Partial) => void; + onSave: () => void; +} + +export function WorkOrderVendorEditorDialog({ + vendorEditor, + isLoading, + isSaving, + error, + onClose, + onChange, + onSave, +}: WorkOrderVendorEditorDialogProps) { + return ( + + Edit assigned vendor + + + + {vendorEditor?.vendorName} + + {Boolean(error) && {error}} + {isLoading ? ( + + + + ) : ( + <> + onChange({ contactName: event.target.value })} + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + onChange({ phone: formatPhoneMask(event.target.value) })} + error={Boolean( + vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone), + )} + helperText="Use (XXX) XXX-XXXX" + /> + onChange({ email: event.target.value })} + /> + onChange({ notes: event.target.value })} + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + )} + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts index ae48b4b5..ada32016 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts @@ -52,21 +52,13 @@ export function useWizardDateFieldState({ const [rangeStart, setRangeStart] = useState(value || ""); const [rangeEnd, setRangeEnd] = useState(scheduleEnd || ""); - useEffect(() => { - if (!open) { - return; - } - // Keep draft props as source of truth while open so parent clears - // (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd. - setRangeStart(value || ""); - setRangeEnd(scheduleEnd || ""); - }, [open, value, scheduleEnd]); - useEffect(() => { if (open) { + setRangeStart(value || ""); + setRangeEnd(scheduleEnd || ""); setCellMode(initialMode); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); function handleRangeClick(iso: string) { diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx index 8d06d966..3cc42c58 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx @@ -41,7 +41,6 @@ export function WizardStepLocationSiteSelect({ siteCode: loc ? resolveSiteCode(loc) : "", pocName: "", pocPhone: "", - pocNotes: "", }); }} /> diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index 7dbcb199..cb2a7cbf 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -2,7 +2,10 @@ import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; import { Text } from "@/components/ui/text"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import { + ALL_WIZARD_STATUSES, TIME_SLOTS, + WIZARD_STATUS_DOTS, + type WizardWOStatus, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -16,6 +19,7 @@ import { DLG_INPUT_STYLE, } from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; import { + ColorDot, Detail, WizLabel, WizLabelRow, @@ -113,6 +117,8 @@ export function WizardStepVendorTime({ onPatch, }: WizardStepVendorTimeProps) { const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId); + const statusValue = (draft.status || "Incomplete") as WizardWOStatus; + const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles); return ( @@ -228,6 +234,22 @@ export function WizardStepVendorTime({ )} + +
+ Status + } + options={ALL_WIZARD_STATUSES.map((s) => ({ + key: s, + label: s, + left: , + }))} + onPick={(s) => onUpdate("status", s as WizardWOStatus)} + /> +
); } diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts new file mode 100644 index 00000000..47464dfe --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-dispatch-create-controller.ts @@ -0,0 +1,159 @@ +import { useMemo, useState } from "react"; +import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { + useTaskListTemplates, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useDispatchToVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import type { SelectedWorkOrderRef, WorkOrderDetail } from "@/domain/work-orders/types/work-order"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +export function useDispatchCreateController( + workOrder: WorkOrderDetail, + onClose: () => void, + onDispatched: () => void, +) { + const [selectedVendorId, setSelectedVendorId] = useState(null); + const [nteAmount, setNteAmount] = useState(workOrder.vendorNTE || ""); + const [scheduledDate, setScheduledDate] = useState(""); + const [description, setDescription] = useState(workOrder.description || ""); + const [vendorSearch, setVendorSearch] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [customItems, setCustomItems] = useState([]); + const [newCustomItem, setNewCustomItem] = useState(""); + const [selectedWOs, setSelectedWOs] = useState([ + { + id: workOrder.id, + number: workOrder.internalWONumber, + title: workOrder.title, + }, + ]); + const [woSearch, setWoSearch] = useState(""); + + const { data: vendors = [], isLoading: isLoadingVendors } = useWorkOrderVendors( + workOrder.trade, + workOrder.locationZip, + ); + const { data: taskTemplates = [], isLoading: isLoadingTemplates } = useTaskListTemplates(); + const dispatchMutation = useDispatchToVendor(workOrder.id); + const isPending = dispatchMutation.isPending; + + const { data: siteWorkOrdersData } = useWorkOrdersList({ + search: "", + page: 1, + pageSize: 500, + sortBy: "number", + sortDir: "asc", + locationId: workOrder.locationId, + }); + + const siteWorkOrders = useMemo( + () => + (siteWorkOrdersData?.items ?? []).filter((item) => String(item.id) !== String(workOrder.id)), + [siteWorkOrdersData?.items, workOrder.id], + ); + + const filteredVendors = useMemo( + () => + vendors.filter( + (vendor) => + vendor.companyName.toLowerCase().includes(vendorSearch.toLowerCase()) || + vendor.contactName.toLowerCase().includes(vendorSearch.toLowerCase()) || + vendor.tradeSpecialties.toLowerCase().includes(vendorSearch.toLowerCase()), + ), + [vendors, vendorSearch], + ); + + const filteredSiteWOs = useMemo( + () => + siteWorkOrders + .filter((item) => !selectedWOs.some((selected) => String(selected.id) === String(item.id))) + .filter( + (item) => + !woSearch || + item.woNumber.includes(woSearch) || + item.title.toLowerCase().includes(woSearch.toLowerCase()), + ) + .slice(0, 10), + [siteWorkOrders, selectedWOs, woSearch], + ); + + const addSelectedWO = (item: { id: string | number; woNumber: string; title: string }) => { + setSelectedWOs((prev) => [...prev, { id: item.id, number: item.woNumber, title: item.title }]); + setWoSearch(""); + }; + + const removeSelectedWO = (id: string | number) => { + setSelectedWOs((prev) => prev.filter((item) => item.id !== id)); + }; + + const addCustomItem = () => { + if (!newCustomItem.trim()) return; + setCustomItems((prev) => [...prev, newCustomItem.trim()]); + setNewCustomItem(""); + }; + + const removeCustomItem = (index: number) => { + setCustomItems((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = () => { + if (!selectedVendorId) { + return; + } + + dispatchMutation.mutate( + { + workOrderIds: selectedWOs.map((wo) => wo.id), + vendorIds: [selectedVendorId], + nteAmount: nteAmount ? parseFloat(nteAmount) : null, + scheduledDate: scheduledDate || null, + description, + taskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId, 10) : null, + customChecklistItems: customItems.length > 0 ? customItems : null, + }, + { + onSuccess: () => { + onDispatched(); + onClose(); + }, + }, + ); + }; + + return { + selectedVendorId, + setSelectedVendorId, + nteAmount, + setNteAmount, + scheduledDate, + setScheduledDate, + description, + setDescription, + vendorSearch, + setVendorSearch, + selectedTemplateId, + setSelectedTemplateId, + customItems, + newCustomItem, + setNewCustomItem, + addCustomItem, + removeCustomItem, + selectedWOs, + addSelectedWO, + removeSelectedWO, + woSearch, + setWoSearch, + vendors: filteredVendors, + isLoadingVendors, + taskTemplates, + isLoadingTemplates, + siteWorkOrders, + filteredSiteWOs, + isPending, + handleClose: guardDialogClose(isPending, onClose), + handleSubmit, + }; +} + +export type DispatchCreateController = ReturnType; diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts index 5c681db8..948975bc 100644 --- a/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts +++ b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts @@ -1,11 +1,6 @@ import { useMemo, useState } from "react"; -import { - useApproveUplift, - useDenyUplift, - useUpliftsForDispatch, -} from "@/domain/uplifts/use-cases/use-uplift-actions"; -import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; import type { DispatchDetail } from "@/domain/work-orders/types/work-order"; +import { useDispatchUpliftController } from "@/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller"; import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useAddChecklistItem, @@ -52,8 +47,8 @@ export function useDispatchDetailController( workOrderId: string | number, ) { const { user } = useAuthContext(); - const { data: dispatch, isLoading } = useDispatchDetail(dispatchId); - const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId); + const { data: dispatch, isLoading, isFetching, refetch } = useDispatchDetail(dispatchId); + const uplift = useDispatchUpliftController(dispatchId); const [editing, setEditing] = useState(false); const [form, setForm] = useState({ @@ -67,10 +62,6 @@ export function useDispatchDetailController( const [commentText, setCommentText] = useState(""); const [sendEmail, setSendEmail] = useState(true); const [sigCaptureType, setSigCaptureType] = useState(null); - const [upliftAction, setUpliftAction] = useState<{ - type: "approve" | "deny"; - uplift: UpliftRequest; - } | null>(null); const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId); const addCommentMutation = useAddDispatchComment(dispatchId); @@ -78,8 +69,6 @@ export function useDispatchDetailController( const addChecklistMutation = useAddChecklistItem(dispatchId); const addSignoffMutation = useAddDispatchSignoff(dispatchId); const verifyMutation = useVerifyDispatch(dispatchId, workOrderId); - const approveUpliftMutation = useApproveUplift(dispatchId); - const denyUpliftMutation = useDenyUplift(dispatchId); const userName = (typeof user?.name === "string" ? user.name : undefined) ?? @@ -138,29 +127,22 @@ export function useDispatchDetailController( ); }; - const handleUpliftConfirm = (note: string) => { - if (!upliftAction) { - return; - } - - if (upliftAction.type === "approve") { - approveUpliftMutation.mutate( - { id: upliftAction.uplift.id, note }, - { onSuccess: () => setUpliftAction(null) }, - ); - return; - } - - denyUpliftMutation.mutate( - { id: upliftAction.uplift.id, note }, - { onSuccess: () => setUpliftAction(null) }, - ); - }; + const isMutating = + updateDispatchMutation.isPending || + addCommentMutation.isPending || + updateChecklistMutation.isPending || + addChecklistMutation.isPending || + addSignoffMutation.isPending || + verifyMutation.isPending || + uplift.isMutating; return { dispatch, isLoading, - uplifts, + isFetching, + refetch, + uplifts: uplift.uplifts, + isLoadingUplifts: uplift.isLoadingUplifts, editing, form, setForm, @@ -172,24 +154,27 @@ export function useDispatchDetailController( setSendEmail, sigCaptureType, setSigCaptureType, - upliftAction, - setUpliftAction, + upliftAction: uplift.upliftAction, + setUpliftAction: uplift.setUpliftAction, isLocked, + isMutating, userName, verifyState, beginEditing, handleSave, handleCancelDispatch, handleComment, - handleUpliftConfirm, + handleUpliftConfirm: uplift.handleUpliftConfirm, setEditing, updateDispatchMutation, updateChecklistMutation, addChecklistMutation, addSignoffMutation, verifyMutation, - approveUpliftMutation, - denyUpliftMutation, + approveUpliftMutation: uplift.approveUpliftMutation, + denyUpliftMutation: uplift.denyUpliftMutation, addCommentMutation, }; } + +export type DispatchDetailController = ReturnType; diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts new file mode 100644 index 00000000..acab0985 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-dispatch-uplift-controller.ts @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { + useApproveUplift, + useDenyUplift, + useUpliftsForDispatch, +} from "@/domain/uplifts/use-cases/use-uplift-actions"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; + +type UpliftAction = { type: "approve" | "deny"; uplift: UpliftRequest }; + +/** + * Owns the uplift approve/deny decision workflow for a dispatch: the pending + * uplifts list, which uplift (if any) the user is currently deciding on, and + * the mutations backing that decision. + */ +export function useDispatchUpliftController(dispatchId: string | number) { + const { data: uplifts = [], isLoading: isLoadingUplifts } = useUpliftsForDispatch(dispatchId); + const [upliftAction, setUpliftAction] = useState(null); + const approveUpliftMutation = useApproveUplift(dispatchId); + const denyUpliftMutation = useDenyUplift(dispatchId); + + const handleUpliftConfirm = (note: string) => { + if (!upliftAction) { + return; + } + + if (upliftAction.type === "approve") { + approveUpliftMutation.mutate( + { id: upliftAction.uplift.id, note }, + { onSuccess: () => setUpliftAction(null) }, + ); + return; + } + + denyUpliftMutation.mutate( + { id: upliftAction.uplift.id, note }, + { onSuccess: () => setUpliftAction(null) }, + ); + }; + + return { + uplifts, + isLoadingUplifts, + upliftAction, + setUpliftAction, + approveUpliftMutation, + denyUpliftMutation, + handleUpliftConfirm, + isMutating: approveUpliftMutation.isPending || denyUpliftMutation.isPending, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-vendor-editor.ts b/src/app/(protected)/workorders/_hooks/use-vendor-editor.ts new file mode 100644 index 00000000..e9fe22cd --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-vendor-editor.ts @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import { useUpdateWorkOrderVendor } from "@/domain/work-orders/use-cases/use-work-order-mutations"; + +export interface VendorEditorState { + vendorId: string | number; + vendorName: string; + contactName: string; + phone: string; + email: string; + notes: string; +} + +export function formatPhoneMask(input: string): string { + const digits = input.replace(/\D/g, "").slice(0, 10); + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + +export function isVendorEditorValid(editor: VendorEditorState): boolean { + const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone); + const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email); + return ( + phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200 + ); +} + +/** + * Owns the "edit assigned vendor" dialog state for a work order: loading the + * current vendor contact details, tracking in-flight edits, and saving them + * back via `useUpdateWorkOrderVendor`. + */ +export function useVendorEditor(workOrderId: string, onSaved: () => void) { + const vendorMutation = useUpdateWorkOrderVendor(workOrderId); + const [vendorEditor, setVendorEditor] = useState(null); + const [vendorEditorLoading, setVendorEditorLoading] = useState(false); + const [vendorEditorError, setVendorEditorError] = useState(""); + + const openVendorEditor = async (vendorId: string | number, vendorName: string) => { + setVendorEditorError(""); + setVendorEditorLoading(true); + setVendorEditor({ + vendorId, + vendorName, + contactName: "", + phone: "", + email: "", + notes: "", + }); + + try { + const vendor = await vendorsApi.getById(vendorId); + setVendorEditor({ + vendorId, + vendorName: vendor.companyName || vendorName, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + notes: vendor.notes, + }); + } catch (vendorError) { + setVendorEditorError( + vendorError instanceof Error ? vendorError.message : "Unable to load vendor details", + ); + } finally { + setVendorEditorLoading(false); + } + }; + + const closeVendorEditor = () => { + if (!vendorMutation.isPending) setVendorEditor(null); + }; + + const saveVendorEditor = () => { + if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return; + const numericWorkOrderId = Number(workOrderId); + if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { + setVendorEditorError("A valid work order is required"); + return; + } + + vendorMutation.mutate( + { + vendorId: vendorEditor.vendorId, + payload: { + workOrderId: numericWorkOrderId, + contactName: vendorEditor.contactName.trim(), + phone: vendorEditor.phone, + email: vendorEditor.email.trim(), + notes: vendorEditor.notes.trim(), + }, + }, + { + onSuccess: () => { + setVendorEditor(null); + onSaved(); + }, + onError: (vendorError) => setVendorEditorError(vendorError.message), + }, + ); + }; + + return { + vendorEditor, + setVendorEditor, + vendorEditorLoading, + vendorEditorError, + vendorMutation, + openVendorEditor, + closeVendorEditor, + saveVendorEditor, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts new file mode 100644 index 00000000..9053c006 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-controller.ts @@ -0,0 +1,82 @@ +import { useMemo, useState } from "react"; +import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { + useAddWorkOrderComment, + useChangeWorkOrderAssignment, + useChangeWorkOrderStatus, +} from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor"; + +export type WorkOrderActivityTab = "customer" | "internal" | "audit"; + +export function useWorkOrderViewController(workOrderId: string) { + const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); + const { data: users = [] } = useWorkOrderUsers(); + const statusMutation = useChangeWorkOrderStatus(workOrderId); + const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); + const commentMutation = useAddWorkOrderComment(workOrderId); + const vendorEditor = useVendorEditor(workOrderId, () => void refetch()); + + const [activeTab, setActiveTab] = useState("customer"); + const [commentText, setCommentText] = useState(""); + const [dispatchOpen, setDispatchOpen] = useState(false); + const [dispatchDetailId, setDispatchDetailId] = useState(null); + const [statusAnchor, setStatusAnchor] = useState(null); + const [assignAnchor, setAssignAnchor] = useState(null); + + const customerComments = useMemo( + () => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"), + [wo?.comments], + ); + const internalComments = useMemo( + () => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"), + [wo?.comments], + ); + const auditLog = wo?.auditLog ?? []; + + const handleCommentSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!commentText.trim()) { + return; + } + if (activeTab !== "customer" && activeTab !== "internal") { + return; + } + + commentMutation.mutate( + { text: commentText.trim(), commentType: activeTab }, + { onSuccess: () => setCommentText("") }, + ); + }; + + return { + wo, + isLoading, + error, + refetch, + users, + statusMutation, + assignmentMutation, + commentMutation, + vendorEditor, + activeTab, + setActiveTab, + commentText, + setCommentText, + dispatchOpen, + setDispatchOpen, + dispatchDetailId, + setDispatchDetailId, + statusAnchor, + setStatusAnchor, + assignAnchor, + setAssignAnchor, + customerComments, + internalComments, + auditLog, + handleCommentSubmit, + }; +} + +export type WorkOrderViewController = ReturnType; diff --git a/src/components/common/signature-capture-fields.tsx b/src/components/common/signature-capture-fields.tsx new file mode 100644 index 00000000..43e68eb6 --- /dev/null +++ b/src/components/common/signature-capture-fields.tsx @@ -0,0 +1,91 @@ +import { Box, Button, Tab, Tabs, TextField, Typography } from "@mui/material"; +import type { RefObject } from "react"; + +interface SignatureCaptureFieldsProps { + isPending: boolean; + mode: "draw" | "type"; + onModeChange: (mode: "draw" | "type") => void; + signerName: string; + onSignerNameChange: (value: string) => void; + typedName: string; + onTypedNameChange: (value: string) => void; + canvasRef: RefObject; + startDraw: (event: React.MouseEvent | React.TouchEvent) => void; + draw: (event: React.MouseEvent | React.TouchEvent) => void; + stopDraw: () => void; + clearCanvas: () => void; +} + +export function SignatureCaptureFields({ + isPending, + mode, + onModeChange, + signerName, + onSignerNameChange, + typedName, + onTypedNameChange, + canvasRef, + startDraw, + draw, + stopDraw, + clearCanvas, +}: SignatureCaptureFieldsProps) { + return ( + <> + onSignerNameChange(event.target.value)} + placeholder="Enter signer's full name" + fullWidth + size="small" + disabled={isPending} + /> + + onModeChange(value)}> + + + + + {mode === "draw" ? ( + + + + + ) : ( + + onTypedNameChange(event.target.value)} + placeholder="Type your name" + fullWidth + size="small" + disabled={isPending} + /> + {Boolean(typedName) && ( + + {typedName} + + )} + + )} + + ); +} diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx index 9c14cf18..c7360b9d 100644 --- a/src/components/common/signature-capture.tsx +++ b/src/components/common/signature-capture.tsx @@ -1,119 +1,38 @@ -import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars"; -import { useEffect, useRef, useState } from "react"; +import { guardDialogClose } from "@/lib/dialog-guard"; +import { useState } from "react"; import { createPortal } from "react-dom"; import CloseIcon from "@mui/icons-material/Close"; import { - Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, - Tab, - Tabs, - TextField, - Typography, } from "@mui/material"; +import { SignatureCaptureFields } from "@/components/common/signature-capture-fields"; +import { useSignatureCanvas } from "@/components/common/use-signature-canvas"; interface SignatureCaptureProps { title?: string; onClose: () => void; onSave: (signature: string, method: "drawn" | "typed", name: string) => void; + isPending?: boolean; } -export function SignatureCapture({ onClose, onSave, title }: SignatureCaptureProps) { +export function SignatureCapture({ + onClose, + onSave, + title, + isPending = false, +}: SignatureCaptureProps) { const [mode, setMode] = useState<"draw" | "type">("draw"); const [typedName, setTypedName] = useState(""); const [signerName, setSignerName] = useState(""); - const canvasRef = useRef(null); - const isDrawing = useRef(false); - - useEffect(() => { - if (mode === "draw" && canvasRef.current) { - const canvas = canvasRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background); - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground); - ctx.lineWidth = 2; - ctx.lineCap = "round"; - } - }, [mode]); - - const getPos = (event: React.MouseEvent | React.TouchEvent) => { - const canvas = canvasRef.current; - if (!canvas) { - return { x: 0, y: 0 }; - } - - const rect = canvas.getBoundingClientRect(); - const touch = "touches" in event ? event.touches[0] : event; - if (!touch) { - return { x: 0, y: 0 }; - } - - return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; - }; - - const startDraw = (event: React.MouseEvent | React.TouchEvent) => { - event.preventDefault(); - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - isDrawing.current = true; - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - - const pos = getPos(event); - ctx.beginPath(); - ctx.moveTo(pos.x, pos.y); - }; - - const draw = (event: React.MouseEvent | React.TouchEvent) => { - event.preventDefault(); - if (!isDrawing.current || !canvasRef.current) { - return; - } - - const ctx = canvasRef.current.getContext("2d"); - if (!ctx) { - return; - } - - const pos = getPos(event); - ctx.lineTo(pos.x, pos.y); - ctx.stroke(); - }; - - const stopDraw = () => { - isDrawing.current = false; - }; - - const clearCanvas = () => { - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - - ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background); - ctx.fillRect(0, 0, canvas.width, canvas.height); - }; + const { canvasRef, startDraw, draw, stopDraw, clearCanvas } = useSignatureCanvas(mode); const handleSave = () => { - if (!signerName.trim()) { + if (isPending || !signerName.trim()) { return; } @@ -134,77 +53,41 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro }; const dialog = ( - + {title || "Capture Signature"} - + - setSignerName(event.target.value)} - placeholder="Enter signer's full name" - fullWidth - size="small" + - - setMode(value)}> - - - - - {mode === "draw" ? ( - - - - - ) : ( - - setTypedName(event.target.value)} - placeholder="Type your name" - fullWidth - size="small" - /> - {Boolean(typedName) && ( - - {typedName} - - )} - - )} - + diff --git a/src/components/common/use-signature-canvas.ts b/src/components/common/use-signature-canvas.ts new file mode 100644 index 00000000..8d8904c8 --- /dev/null +++ b/src/components/common/use-signature-canvas.ts @@ -0,0 +1,85 @@ +import { CSS_VAR_FALLBACKS, getCssVar } from "@/lib/theme/css-vars"; +import { useEffect, useRef } from "react"; + +export function useSignatureCanvas(mode: "draw" | "type") { + const canvasRef = useRef(null); + const isDrawing = useRef(false); + + useEffect(() => { + if (mode !== "draw" || !canvasRef.current) { + return; + } + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background); + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = getCssVar("--foreground", CSS_VAR_FALLBACKS.foreground); + ctx.lineWidth = 2; + ctx.lineCap = "round"; + }, [mode]); + + const getPos = (event: React.MouseEvent | React.TouchEvent) => { + const canvas = canvasRef.current; + if (!canvas) { + return { x: 0, y: 0 }; + } + const rect = canvas.getBoundingClientRect(); + const touch = "touches" in event ? event.touches[0] : event; + if (!touch) { + return { x: 0, y: 0 }; + } + return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; + }; + + const startDraw = (event: React.MouseEvent | React.TouchEvent) => { + event.preventDefault(); + const canvas = canvasRef.current; + if (!canvas) { + return; + } + isDrawing.current = true; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const pos = getPos(event); + ctx.beginPath(); + ctx.moveTo(pos.x, pos.y); + }; + + const draw = (event: React.MouseEvent | React.TouchEvent) => { + event.preventDefault(); + if (!isDrawing.current || !canvasRef.current) { + return; + } + const ctx = canvasRef.current.getContext("2d"); + if (!ctx) { + return; + } + const pos = getPos(event); + ctx.lineTo(pos.x, pos.y); + ctx.stroke(); + }; + + const stopDraw = () => { + isDrawing.current = false; + }; + + const clearCanvas = () => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + ctx.fillStyle = getCssVar("--background", CSS_VAR_FALLBACKS.background); + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + + return { canvasRef, startDraw, draw, stopDraw, clearCanvas }; +} diff --git a/src/domain/auth/api/auth-api.ts b/src/domain/auth/api/auth-api.ts index bdbafe16..279baaa7 100644 --- a/src/domain/auth/api/auth-api.ts +++ b/src/domain/auth/api/auth-api.ts @@ -6,11 +6,6 @@ import { API_PATHS } from "@/api/api-paths"; import { apiPost } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; -import { - disableAuthBypassForSession, - getBypassAuthUser, - isAuthBypassEnabled, -} from "@/lib/auth/auth-bypass"; import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage"; import type { AuthUser, @@ -27,18 +22,10 @@ export async function login(credentials: LoginCredentials): Promise { } export async function logout(): Promise { - if (isAuthBypassEnabled()) { - disableAuthBypassForSession(); - } - clearAuth(); } export async function getSession(): Promise { - if (isAuthBypassEnabled()) { - return getBypassAuthUser(); - } - const user = getAuthUser(); if (!user || !isSessionValid(user)) { diff --git a/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts index 9420dd2e..c7cd240b 100644 --- a/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts +++ b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts @@ -1,10 +1,9 @@ import { env } from "@/lib/env"; -import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass"; export function isWoWizardMockEnabled(): boolean { return env.isDev; } export function shouldUseMockWoWizardCreate(locationId: string): boolean { - return isAuthBypassEnabled() || locationId.startsWith("mock-"); + return env.isDev && locationId.startsWith("mock-"); } diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index 58ae0685..95909519 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -85,12 +85,12 @@ export interface AddDispatchSignoffInput { signatureMethod: string; } -interface ChangeWorkOrderAssignmentInput { +export interface ChangeWorkOrderAssignmentInput { userId: string | number; userName: string; } -interface UpdateWorkOrderVendorInput { +export interface UpdateWorkOrderVendorInput { vendorId: string | number; payload: WorkOrderVendorUpdatePayload; } diff --git a/src/lib/auth/auth-bypass.ts b/src/lib/auth/auth-bypass.ts deleted file mode 100644 index 40e12fea..00000000 --- a/src/lib/auth/auth-bypass.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AuthUser } from "@/domain/auth/types/auth-user"; -import { env } from "@/lib/env"; - -const BYPASS_DISABLED_KEY = "auth-bypass-disabled"; - -export function isAuthBypassEnabled(): boolean { - if (!env.isDev || !env.authBypass) { - return false; - } - - if (typeof window !== "undefined" && sessionStorage.getItem(BYPASS_DISABLED_KEY)) { - return false; - } - - return true; -} - -export function getBypassAuthUser(): AuthUser { - return { - token: "dev-bypass-token", - expiration: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), - email: "dev-bypass@seahaven.test", - userRoles: "Admin", - phoneNumber: "", - fullname: "Dev Bypass User", - id: "dev-bypass", - }; -} - -export function disableAuthBypassForSession(): void { - if (typeof window === "undefined") { - return; - } - - sessionStorage.setItem(BYPASS_DISABLED_KEY, "1"); -} diff --git a/src/lib/env.ts b/src/lib/env.ts index d22d5b55..51b5d14a 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,5 +1,4 @@ export const env = { apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api", isDev: import.meta.env.DEV, - authBypass: import.meta.env.VITE_AUTH_BYPASS === "true", } as const; diff --git a/src/lib/query/query-client.ts b/src/lib/query/query-client.ts index 1223fc2b..bef74cd1 100644 --- a/src/lib/query/query-client.ts +++ b/src/lib/query/query-client.ts @@ -1,5 +1,6 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; +import { setupQueryBroadcast } from "@/lib/query/setup-query-broadcast"; export const queryClient = new QueryClient({ queryCache: new QueryCache({ @@ -30,4 +31,6 @@ export const queryClient = new QueryClient({ }, }); +setupQueryBroadcast(queryClient); + export default queryClient; diff --git a/src/lib/query/setup-query-broadcast.ts b/src/lib/query/setup-query-broadcast.ts new file mode 100644 index 00000000..3b5f24d6 --- /dev/null +++ b/src/lib/query/setup-query-broadcast.ts @@ -0,0 +1,19 @@ +import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental"; +import type { QueryClient } from "@tanstack/react-query"; + +/** Same-origin channel name for cross-tab QueryClient sync. */ +export const QUERY_BROADCAST_CHANNEL = "seahaven-admin-query"; + +/** + * Syncs QueryClient cache (invalidations + data) across browser tabs via BroadcastChannel. + * No-op outside the browser and during Vitest runs. + */ +export function setupQueryBroadcast(queryClient: QueryClient): void { + if (typeof window === "undefined") return; + if (import.meta.env.MODE === "test") return; + + broadcastQueryClient({ + queryClient, + broadcastChannel: QUERY_BROADCAST_CHANNEL, + }); +} diff --git a/src/test/components/common/signature-capture.test.tsx b/src/test/components/common/signature-capture.test.tsx new file mode 100644 index 00000000..d9a8fb62 --- /dev/null +++ b/src/test/components/common/signature-capture.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { SignatureCapture } from "@/components/common/signature-capture"; +import { renderWithProviders } from "@/test/test-utils"; + +/** + * Mirrors how a consumer wires `isPending` from a mutation: the flag flips to + * `true` synchronously once `onSave` fires and only clears when the caller + * decides to (e.g. on mutation settle), never inside `SignatureCapture` itself. + */ +function SignatureCaptureHarness({ onSave }: { onSave: () => void }) { + const [isPending, setIsPending] = useState(false); + + return ( + { + setIsPending(true); + onSave(); + }} + /> + ); +} + +async function fillTypedSignature(user: ReturnType) { + await user.type(screen.getByPlaceholderText("Enter signer's full name"), "Jane Doe"); + await user.click(screen.getByRole("tab", { name: "Type" })); + await user.type(screen.getByPlaceholderText("Type your name"), "Jane Doe"); +} + +describe("SignatureCapture", () => { + it("disables Save Signature and closing controls while isPending", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + }); + + it("calls onSave exactly once when Save Signature is double-clicked before the mutation settles", async () => { + const user = userEvent.setup(); + const handleSave = vi.fn(); + renderWithProviders(, { withAuth: false }); + + await fillTypedSignature(user); + + const saveButton = screen.getByRole("button", { name: "Save Signature" }); + fireEvent.click(saveButton); + fireEvent.click(saveButton); + + expect(handleSave).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled(); + }); +}); diff --git a/src/test/lib/query/setup-query-broadcast.test.ts b/src/test/lib/query/setup-query-broadcast.test.ts new file mode 100644 index 00000000..bc203093 --- /dev/null +++ b/src/test/lib/query/setup-query-broadcast.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +const broadcastQueryClient = vi.fn(); + +vi.mock("@tanstack/query-broadcast-client-experimental", () => ({ + broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args), +})); + +describe("setupQueryBroadcast", () => { + beforeEach(() => { + broadcastQueryClient.mockClear(); + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not call broadcastQueryClient when MODE is test", async () => { + vi.stubEnv("MODE", "test"); + const { setupQueryBroadcast } = await import("@/lib/query/setup-query-broadcast"); + const client = new QueryClient(); + + setupQueryBroadcast(client); + + expect(broadcastQueryClient).not.toHaveBeenCalled(); + }); + + it("calls broadcastQueryClient with the app channel when MODE is not test", async () => { + vi.stubEnv("MODE", "development"); + vi.resetModules(); + + const { setupQueryBroadcast, QUERY_BROADCAST_CHANNEL } = + await import("@/lib/query/setup-query-broadcast"); + const client = new QueryClient(); + + setupQueryBroadcast(client); + + expect(broadcastQueryClient).toHaveBeenCalledTimes(1); + expect(broadcastQueryClient).toHaveBeenCalledWith({ + queryClient: client, + broadcastChannel: QUERY_BROADCAST_CHANNEL, + }); + expect(QUERY_BROADCAST_CHANNEL).toBe("seahaven-admin-query"); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index 97877956..f2b26ff5 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,11 +1,44 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; -import { afterEach } from "vitest"; +import { afterEach, vi } from "vitest"; afterEach(() => { cleanup(); }); +function localStorageIsUsable(): boolean { + try { + const probe = "__vitest_ls_probe__"; + globalThis.localStorage.setItem(probe, "1"); + const ok = globalThis.localStorage.getItem(probe) === "1"; + globalThis.localStorage.removeItem(probe); + return ok; + } catch { + return false; + } +} + +// Node 25+ / some harnesses lack a stable localStorage — provide an in-memory mock when needed. +if (!localStorageIsUsable()) { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + get length() { + return store.size; + }, + key: (index: number) => [...store.keys()][index] ?? null, + }); +} + Object.defineProperty(window, "matchMedia", { writable: true, value: (query: string) => ({ diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 7c85de03..4df1f98f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,8 +3,6 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_API_TARGET: string; - readonly VITE_AUTH_BYPASS?: string; - readonly VITE_WO_TABLE_MOCK?: string; } interface ImportMeta {