From d9d6334f57af6b58e51db63daba3e2a6afb3c2c6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 24 Aug 2026 09:31:29 -0300 Subject: [PATCH] fix(work-orders): lock completed status and legacy detail Keep Flag, comments, and extra docs writable after completion, and hide the completed status menu. --- src/app/(protected)/workorders/[id].tsx | 20 ++- .../detail/use-slide-over-edit-state.ts | 5 +- .../detail/use-work-order-slide-over.ts | 125 ++++++++----- .../list/table/cells/status-cell.tsx | 24 +++ .../list/table/wo-table-row-service-cells.tsx | 1 + .../view/work-order-dispatches-accordion.tsx | 4 +- .../view/work-order-vendor-editor-dialog.tsx | 34 ++-- .../view/work-order-view-details.tsx | 60 ++++--- .../view/work-order-view-header.tsx | 79 ++------ .../view/work-order-view-status-control.tsx | 103 +++++++++++ .../wizard/wizard-step-vendor-time.tsx | 104 ++++++----- .../_hooks/use-work-order-view-page.ts | 5 + .../work-orders/utils/work-order-lock.ts | 1 - ...de-over-completed-comments-extras.test.tsx | 27 +++ .../status-cell-pending-uplift.test.tsx | 19 ++ .../wo-table-row-completed-lock.test.tsx | 29 +++ .../work-order-view-completed-lock.test.tsx | 169 ++++++++++++++++++ 17 files changed, 607 insertions(+), 202 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/view/work-order-view-status-control.tsx create mode 100644 src/test/app/(protected)/workorders/work-order-view-completed-lock.test.tsx diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index a81d6f04..6741c4ea 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -18,6 +18,10 @@ import { getClosabilityGaps, isCompletionStatusLabel, } from "@/domain/work-orders/utils/wo-closability"; +import { + isWorkOrderCoreLocked, + isWorkOrderFullyLocked, +} from "@/domain/work-orders/utils/work-order-lock"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order"; export default function WorkOrderViewPage() { @@ -42,8 +46,10 @@ export default function WorkOrderViewPage() { } const wo = view.wo; + const isLocked = isWorkOrderCoreLocked(wo.status) || isWorkOrderFullyLocked(wo.status); const handleStatusChange = (status: string) => { + if (isLocked) return; const isCompletionStatus = isCompletionStatusLabel(status); if ( isCompletionStatus && @@ -68,6 +74,7 @@ export default function WorkOrderViewPage() { onStatusAnchorChange={view.setStatusAnchor} onStatusChange={handleStatusChange} onOpenDispatch={() => view.setDispatchOpen(true)} + readOnly={isLocked} /> view.assignmentMutation.mutate({ userId, userName })} - onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })} + onAssign={(userId, userName) => { + if (isLocked) return; + view.assignmentMutation.mutate({ userId, userName }); + }} + onUnassign={() => { + if (isLocked) return; + view.assignmentMutation.mutate({ userId: "", userName: "" }); + }} + readOnly={isLocked} /> @@ -117,6 +132,7 @@ export default function WorkOrderViewPage() { loading={view.vendorEditorLoading} error={view.vendorEditorError} isSaving={view.vendorMutation.isPending} + viewOnly={isLocked} onClose={() => { if (!view.vendorMutation.isPending) view.setVendorEditor(null); }} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 90c6fa03..e8b1b8b9 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -162,10 +162,7 @@ export function useSlideOverEditState({ }; const startEditing = () => { - if (!infoSource) return; - if (isSlideOverInfoLocked(infoSource.status)) { - return; - } + if (!infoSource || isSlideOverInfoLocked(infoSource.status)) return; setDraft(infoSource); setBaseline(infoSource); setEditing(true); diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index 09aaa9fc..dee476ec 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -24,6 +24,7 @@ import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { useAuthContext } from "@/providers/auth-context"; import { useSlideOverEditState } from "./use-slide-over-edit-state"; import { useSlideOverMediaState } from "./use-slide-over-media-state"; +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { SlideOverSaveOptions } from "./work-order-slide-over"; export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras"; @@ -36,6 +37,75 @@ type UseWorkOrderSlideOverArgs = { saving?: boolean; }; +type CompletionUploadMutate = ( + input: { file: File; workOrderVersion: string }, + options: { onSuccess: () => Promise | void }, +) => void; + +function uploadSlideOverCompletionPdf({ + file, + row, + infoSource, + onSave, + mutate, +}: { + file: File; + row: WorkOrderTableRow | null; + infoSource: WorkOrderTableRow | null | undefined; + onSave: UseWorkOrderSlideOverArgs["onSave"]; + mutate: CompletionUploadMutate; +}) { + const version = infoSource?.rowVersion; + if (!version || !row || !infoSource) { + toast.error("Missing work order version. Refresh and try again."); + return; + } + mutate( + { file, workOrderVersion: version }, + { + onSuccess: async () => { + const patch: WorkOrderTablePatch = { docStatus: "Yes" }; + if (!infoSource.completedDate) { + patch.completedDate = todayIso(); + } + try { + await new Promise((resolve, reject) => { + onSave(row.id, patch, { + onSuccess: () => resolve(), + onError: (error) => reject(error), + }); + }); + } catch { + toast.error("Uploaded PDF but failed to update document status. Refresh and retry."); + } + }, + }, + ); +} + +function resolveSlideOverSource( + detail: WorkOrderBoardDetail | undefined, + row: WorkOrderTableRow | null, +) { + const detailForRow = + detail && row && String(detail.info.id) === String(row.id) ? detail : undefined; + return { detailForRow, infoSource: detailForRow?.info ?? row }; +} + +function slideOverReadFlags(status: string | null | undefined) { + return { + isCancelled: isWorkOrderFullyLocked(status), + isCompleted: isWorkOrderCompleted(status), + isDocumentsReadOnly: isWorkOrderDocumentsReadOnly(status), + isCommentsReadOnly: !canMutateWorkOrderComments(status), + isExtrasReadOnly: !canMutateWorkOrderExtraDocs(status), + }; +} + +function slideOverErrorMessage(error: unknown) { + return error instanceof Error ? error.message : "Failed to load work order details."; +} + export function useWorkOrderSlideOver({ row, editMode, @@ -60,16 +130,8 @@ export function useWorkOrderSlideOver({ const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); const [tab, setTab] = useState("info"); - - // Prefer row until detail for this id arrives — avoids cross-WO bleed while loading. - const detailForRow = - detail && row && String(detail.info.id) === String(row.id) ? detail : undefined; - const infoSource = detailForRow?.info ?? row; - const isCancelled = isWorkOrderFullyLocked(infoSource?.status); - const isCompleted = isWorkOrderCompleted(infoSource?.status); - const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status); - const isCommentsReadOnly = !canMutateWorkOrderComments(infoSource?.status); - const isExtrasReadOnly = !canMutateWorkOrderExtraDocs(infoSource?.status); + const { detailForRow, infoSource } = resolveSlideOverSource(detail, row); + const readFlags = slideOverReadFlags(infoSource?.status); const { completionMedia, @@ -101,38 +163,17 @@ export function useWorkOrderSlideOver({ }); const uploadCompletionPdf = (file: File) => { - const version = infoSource?.rowVersion; - if (!version || !row || !infoSource) { - toast.error("Missing work order version. Refresh and try again."); - return; - } - uploadCompletionMutation.mutate( - { file, workOrderVersion: version }, - { - onSuccess: async () => { - // Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes. - const patch: WorkOrderTablePatch = { docStatus: "Yes" }; - if (!infoSource.completedDate) { - patch.completedDate = todayIso(); - } - try { - await new Promise((resolve, reject) => { - onSave(row.id, patch, { - onSuccess: () => resolve(), - onError: (error) => reject(error), - }); - }); - } catch { - toast.error("Uploaded PDF but failed to update document status. Refresh and retry."); - } - }, - }, - ); + uploadSlideOverCompletionPdf({ + file, + row, + infoSource, + onSave, + mutate: uploadCompletionMutation.mutate, + }); }; const showDetailSkeleton = isLoading && !detailForRow; - const errorMessage = - error instanceof Error ? error.message : "Failed to load work order details."; + const errorMessage = slideOverErrorMessage(error); return { user, @@ -147,11 +188,7 @@ export function useWorkOrderSlideOver({ setTab, detailForRow, infoSource, - isCancelled, - isCompleted, - isDocumentsReadOnly, - isCommentsReadOnly, - isExtrasReadOnly, + ...readFlags, completionMedia, extrasUploads, mediaPending, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index b90fcbb0..d55176bd 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -15,14 +15,33 @@ import { import { HoverChevron } from "./hover-chevron"; import { WoTableStatusLabel } from "./wo-table-status-label"; +const COMPLETED_STATUS_LOCKED_LABEL = "Status is locked because this work order is completed"; + type StatusCellProps = { wo: WorkOrderTableRow; onChangeStatus: (status: WizardWOStatus) => void; onOpenSlideOver: () => void; onPastDueAttempt?: () => void; onRequestComplete?: () => void; + readOnly?: boolean; }; +function ReadOnlyStatusLabel({ status }: { status: string }) { + return ( + + {status ? ( + + ) : ( + — + )} + + ); +} + function rowClosabilityGaps(wo: WorkOrderTableRow) { return getClosabilityGaps(tableRowToClosabilityInput(wo)); } @@ -104,10 +123,15 @@ export function StatusCell({ onOpenSlideOver, onPastDueAttempt, onRequestComplete, + readOnly = false, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); + if (readOnly) { + return ; + } + if (wo.isPastDue) { return ( diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index bbd87d38..3d3e3fc6 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -91,6 +91,7 @@ export function WoTableRowServiceCells({ > onPatchRow({ status })} onOpenSlideOver={() => handlers.onOpenSlideOver(row)} onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)} 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 index 0d77c0f0..aa48451b 100644 --- a/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx +++ b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx @@ -17,6 +17,7 @@ import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; type WorkOrderDispatchesAccordionProps = { wo: WorkOrderDetail; + vendorViewOnly?: boolean; onOpenDispatchDetail: (dispatchId: string | number) => void; onOpenVendorEditor: ( event: React.MouseEvent, @@ -27,6 +28,7 @@ type WorkOrderDispatchesAccordionProps = { export function WorkOrderDispatchesAccordion({ wo, + vendorViewOnly = false, onOpenDispatchDetail, onOpenVendorEditor, }: WorkOrderDispatchesAccordionProps) { @@ -95,7 +97,7 @@ export function WorkOrderDispatchesAccordion({ } disabled={dispatch.vendorId === ""} > - Edit vendor + {vendorViewOnly ? "View vendor" : "Edit vendor"} 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 index b89ef2e7..a1c3014a 100644 --- 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 @@ -19,6 +19,7 @@ type WorkOrderVendorEditorDialogProps = { loading: boolean; error: string; isSaving: boolean; + viewOnly?: boolean; onClose: () => void; onSave: () => void; onEditorChange: ( @@ -31,13 +32,14 @@ export function WorkOrderVendorEditorDialog({ loading, error, isSaving, + viewOnly = false, onClose, onSave, onEditorChange, }: WorkOrderVendorEditorDialogProps) { return ( - Edit assigned vendor + {viewOnly ? "Assigned vendor" : "Edit assigned vendor"} @@ -53,6 +55,7 @@ export function WorkOrderVendorEditorDialog({ onEditorChange((current) => current ? { ...current, contactName: event.target.value } : current, @@ -63,6 +66,7 @@ export function WorkOrderVendorEditorDialog({ onEditorChange((current) => current ? { ...current, phone: formatPhoneMask(event.target.value) } : current, @@ -75,6 +79,7 @@ export function WorkOrderVendorEditorDialog({ label="Email" type="email" value={editor?.email ?? ""} + disabled={viewOnly} onChange={(event) => onEditorChange((current) => current ? { ...current, email: event.target.value } : current, @@ -86,6 +91,7 @@ export function WorkOrderVendorEditorDialog({ multiline minRows={3} value={editor?.notes ?? ""} + disabled={viewOnly} onChange={(event) => onEditorChange((current) => current ? { ...current, notes: event.target.value } : current, @@ -98,16 +104,22 @@ export function WorkOrderVendorEditorDialog({ - - + {viewOnly ? ( + + ) : ( + <> + + + + )} ); diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx index bf283bfd..0c28b48e 100644 --- a/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx +++ b/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx @@ -11,6 +11,7 @@ type WorkOrderViewDetailsProps = { onAssignAnchorChange: (anchor: HTMLElement | null) => void; onAssign: (userId: string | number, userName: string) => void; onUnassign: () => void; + readOnly?: boolean; }; export function WorkOrderViewDetails({ @@ -20,6 +21,7 @@ export function WorkOrderViewDetails({ onAssignAnchorChange, onAssign, onUnassign, + readOnly = false, }: WorkOrderViewDetailsProps) { return ( @@ -31,40 +33,44 @@ export function WorkOrderViewDetails({ - - - Assigned To - - - onAssignAnchorChange(null)} - > - { - onAssignAnchorChange(null); - onUnassign(); - }} + {readOnly ? ( + + ) : ( + + + Assigned To + + + onAssignAnchorChange(null)} > - Unassigned - - {users.map((user) => ( { onAssignAnchorChange(null); - onAssign(user.id, user.name); + onUnassign(); }} > - {user.name} + Unassigned - ))} - - + {users.map((user) => ( + { + onAssignAnchorChange(null); + onAssign(user.id, user.name); + }} + > + {user.name} + + ))} + + + )} diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx index 8a9af6d6..096bf32b 100644 --- a/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx +++ b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx @@ -1,26 +1,7 @@ import { Link as RouterLink, useNavigate } from "react-router"; -import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material"; -import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order"; +import { Breadcrumbs, Button, Link, Stack, Typography } from "@mui/material"; import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; -import { - detailToClosabilityInput, - formatClosabilityMissingTitle, - getClosabilityGaps, - isCompletionStatusLabel, -} from "@/domain/work-orders/utils/wo-closability"; - -const STATUS_COLORS: Record< - string, - "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" -> = { - Open: "primary", - "In Progress": "info", - "On Hold": "warning", - Done: "success", - Cancelled: "default", -}; - -const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements"; +import { WorkOrderViewStatusControl } from "./work-order-view-status-control"; type WorkOrderViewHeaderProps = { wo: WorkOrderDetail; @@ -31,6 +12,7 @@ type WorkOrderViewHeaderProps = { onStatusAnchorChange: (anchor: HTMLElement | null) => void; onStatusChange: (status: string) => void; onOpenDispatch: () => void; + readOnly?: boolean; }; export function WorkOrderViewHeader({ @@ -41,9 +23,9 @@ export function WorkOrderViewHeader({ onStatusAnchorChange, onStatusChange, onOpenDispatch, + readOnly = false, }: WorkOrderViewHeaderProps) { const navigate = useNavigate(); - const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo)); return ( <> @@ -62,54 +44,25 @@ export function WorkOrderViewHeader({ {wo.title} - onStatusAnchorChange(event.currentTarget)} - className="w-fit cursor-pointer" + - onStatusAnchorChange(null)} - > - {WORK_ORDER_STATUS_OPTIONS.map((status) => { - const isCompletionStatus = isCompletionStatusLabel(status); - const isCompletedBlocked = - isCompletionStatus && (!isClosabilityReady || closabilityGaps.length > 0); - const blockedTitle = !isClosabilityReady - ? CLOSABILITY_UNAVAILABLE_TITLE - : formatClosabilityMissingTitle(closabilityGaps); - return ( - { - if (isCompletedBlocked) { - return; - } - onStatusAnchorChange(null); - if (status !== wo.status) { - onStatusChange(status); - } - }} - > - {status} - - ); - })} - - + {Boolean(!readOnly) && ( + + )} diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-status-control.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-status-control.tsx new file mode 100644 index 00000000..85775d8e --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-view-status-control.tsx @@ -0,0 +1,103 @@ +import { Chip, Menu, MenuItem } from "@mui/material"; +import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { + detailToClosabilityInput, + formatClosabilityMissingTitle, + getClosabilityGaps, + isCompletionStatusLabel, +} from "@/domain/work-orders/utils/wo-closability"; + +const STATUS_COLORS: Record< + string, + "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" +> = { + Open: "primary", + "In Progress": "info", + "On Hold": "warning", + Done: "success", + Completed: "success", + Cancelled: "default", + Canceled: "default", +}; + +const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements"; +const LOCKED_STATUS_LABEL = "Status is locked because this work order is completed"; + +type WorkOrderViewStatusControlProps = { + wo: WorkOrderDetail; + readOnly?: boolean; + isClosabilityReady: boolean; + statusAnchor: HTMLElement | null; + onStatusAnchorChange: (anchor: HTMLElement | null) => void; + onStatusChange: (status: string) => void; +}; + +export function WorkOrderViewStatusControl({ + wo, + readOnly = false, + isClosabilityReady, + statusAnchor, + onStatusAnchorChange, + onStatusChange, +}: WorkOrderViewStatusControlProps) { + const chipColor = STATUS_COLORS[wo.status] ?? "default"; + + if (readOnly) { + return ( + + ); + } + + const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo)); + + return ( + <> + onStatusAnchorChange(event.currentTarget)} + className="w-fit cursor-pointer" + /> + onStatusAnchorChange(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => { + const isCompletionStatus = isCompletionStatusLabel(status); + const isCompletedBlocked = + isCompletionStatus && (!isClosabilityReady || closabilityGaps.length > 0); + const blockedTitle = !isClosabilityReady + ? CLOSABILITY_UNAVAILABLE_TITLE + : formatClosabilityMissingTitle(closabilityGaps); + return ( + { + if (isCompletedBlocked) { + return; + } + onStatusAnchorChange(null); + if (status !== wo.status) { + onStatusChange(status); + } + }} + > + {status} + + ); + })} + + + ); +} 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 1407c06a..f98b7ff6 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 @@ -117,6 +117,60 @@ function WizardVendorDetail({ ); } +function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardStepVendorTimeProps) { + const duration = calcDuration(draft.apptStart, draft.apptEnd); + + return ( +
+ Appt Time +
+ + } + search={false} + options={TIME_SLOTS.map((t) => ({ key: t, label: fmt12(t) }))} + onPick={(t) => + onPatch({ + apptStart: t, + apptEnd: draft.apptEnd && draft.apptEnd <= t ? "" : draft.apptEnd, + }) + } + /> + + to + + + } + search={false} + disabled={!draft.apptStart} + options={TIME_SLOTS.filter((t) => !draft.apptStart || t > draft.apptStart).map((t) => ({ + key: t, + label: fmt12(t), + }))} + onPick={(t) => onUpdate("apptEnd", t)} + /> +
+ {Boolean(draft.apptStart && draft.apptEnd && duration) && ( + + Duration {duration} + + )} +
+ ); +} + function buildWizardStatusOptions(closabilityGaps: string[]) { return ALL_WIZARD_STATUSES.map((status) => { const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0; @@ -207,55 +261,7 @@ export function WizardStepVendorTime({ /> )} -
- Appt Time -
- - } - search={false} - options={TIME_SLOTS.map((t) => ({ key: t, label: fmt12(t) }))} - onPick={(t) => - onPatch({ - apptStart: t, - apptEnd: draft.apptEnd && draft.apptEnd <= t ? "" : draft.apptEnd, - }) - } - /> - - to - - - } - search={false} - disabled={!draft.apptStart} - options={TIME_SLOTS.filter((t) => !draft.apptStart || t > draft.apptStart).map((t) => ({ - key: t, - label: fmt12(t), - }))} - onPick={(t) => onUpdate("apptEnd", t)} - /> -
- {draft.apptStart !== "" && - draft.apptEnd !== "" && - Boolean(calcDuration(draft.apptStart, draft.apptEnd)) && ( - - Duration {calcDuration(draft.apptStart, draft.apptEnd)} - - )} -
+
Status diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts index 2965876b..ca371b50 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts @@ -15,6 +15,10 @@ import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-order import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions"; import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board"; +import { + isWorkOrderCoreLocked, + isWorkOrderFullyLocked, +} from "@/domain/work-orders/utils/work-order-lock"; export function useWorkOrderViewPage(workOrderId: string) { const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); @@ -101,6 +105,7 @@ export function useWorkOrderViewPage(workOrderId: string) { }; const saveVendorEditor = () => { + if (isWorkOrderCoreLocked(wo?.status) || isWorkOrderFullyLocked(wo?.status)) return; if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return; const numericWorkOrderId = Number(workOrderId); if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { diff --git a/src/domain/work-orders/utils/work-order-lock.ts b/src/domain/work-orders/utils/work-order-lock.ts index 83d2e74c..19af3d42 100644 --- a/src/domain/work-orders/utils/work-order-lock.ts +++ b/src/domain/work-orders/utils/work-order-lock.ts @@ -31,7 +31,6 @@ const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]); /** * Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use * dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`). - * Those requests may still fail with 422 `ReadOnly` if the backend lock remains total. */ export function canApplyWorkOrderTablePatch( status: string | null | undefined, diff --git a/src/test/app/(protected)/workorders/slide-over-completed-comments-extras.test.tsx b/src/test/app/(protected)/workorders/slide-over-completed-comments-extras.test.tsx index 981583cb..1e4b45d7 100644 --- a/src/test/app/(protected)/workorders/slide-over-completed-comments-extras.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-completed-comments-extras.test.tsx @@ -24,6 +24,33 @@ describe("Completed comments and extra docs remain writable", () => { expect(screen.getByRole("button", { name: /post/i })).toBeInTheDocument(); }); + it("keeps edit on the author's own comment when status is Completed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /edit comment/i })).toBeInTheDocument(); + }); + it("hides the comment composer when status is Canceled", () => { renderWithProviders( { expect(onChangeStatus).not.toHaveBeenCalled(); }); + it("does not open the status menu when readOnly", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed")); + + expect(screen.queryByRole("button", { name: /scheduled/i })).not.toBeInTheDocument(); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + it("allows Completed when technician is empty if company is set", () => { const onChangeStatus = vi.fn(); const onRequestComplete = vi.fn(); diff --git a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx index df84f7f2..c49d8001 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -104,4 +104,33 @@ describe("WoTableRow completed selective lock", () => { expect(screen.queryByTitle("Edit")).not.toBeInTheDocument(); expect(screen.getByTitle("View details")).toBeInTheDocument(); }); + + it("does not open the status menu on a Completed row", () => { + const row = makeRow(); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed")); + + expect(screen.queryByRole("button", { name: /scheduled/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument(); + expect(handlers.onPatch).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/(protected)/workorders/work-order-view-completed-lock.test.tsx b/src/test/app/(protected)/workorders/work-order-view-completed-lock.test.tsx new file mode 100644 index 00000000..1cf2eb72 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-view-completed-lock.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog"; +import { WorkOrderViewDetails } from "@/app/(protected)/workorders/_components/view/work-order-view-details"; +import { WorkOrderViewHeader } from "@/app/(protected)/workorders/_components/view/work-order-view-header"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; + +const baseDetail = { + id: 1, + internalWONumber: "SH-1", + workOrderNumber: "24818", + title: "Test WO", + location: "Lobby", + locationId: "loc-1", + locationZip: "", + assignedTo: "Alex", + dueDate: "", + createdDate: "", + createdDateFull: "", + description: "", + priority: "", + status: "Scheduled", + po: "", + tt: "", + problem: "HVAC", + trade: "", + subTrade: "", + vendorNTE: "", + scheduledDate: "2026-07-15", + completedDate: "2026-07-20", + source: "", + type: "PM", + docStatus: "Yes", + mediaCount: 1, + comments: [], + auditLog: [], + dispatches: [], +} as WorkOrderDetail; + +const vendorEditor = { + vendorId: 7, + vendorName: "Acme", + contactName: "Sam", + phone: "(317) 555-0100", + email: "sam@acme.test", + notes: "Gate code 12", +}; + +function renderHeader(status: string, readOnly: boolean, onStatusChange = vi.fn()) { + return render( + + + , + ); +} + +describe("legacy work-order view completed lock", () => { + it("hides Edit and the status menu on a Completed work order", () => { + const onStatusChange = vi.fn(); + renderHeader("Completed", true, onStatusChange); + + expect(screen.queryByRole("button", { name: /^edit$/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed")); + expect(screen.queryByRole("menuitem", { name: /scheduled/i })).not.toBeInTheDocument(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it("keeps Edit and opens the status menu on a Scheduled work order", () => { + const onStatusAnchorChange = vi.fn(); + render( + + + , + ); + + expect(screen.getByRole("button", { name: /^edit$/i })).toBeInTheDocument(); + fireEvent.click(screen.getByText("Scheduled")); + expect(onStatusAnchorChange).toHaveBeenCalled(); + }); + + it("renders assignee as text without a menu when read-only", () => { + const onAssign = vi.fn(); + render( + , + ); + + expect(screen.getByText("Alex")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /alex/i })).not.toBeInTheDocument(); + expect(onAssign).not.toHaveBeenCalled(); + }); + + it("keeps the assignee menu on a Scheduled work order", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /alex/i })).toBeInTheDocument(); + }); + + it("shows Close only and hides Save on the vendor dialog when view-only", () => { + const onSave = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + expect(screen.getByLabelText("Technician name")).toBeDisabled(); + }); + + it("keeps Save on the vendor dialog when editable", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument(); + expect(screen.getByLabelText("Technician name")).toBeEnabled(); + }); +});