diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 8c63b1b0..6741c4ea 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,4 +1,5 @@ import { useParams } from "react-router"; +import { useState } from "react"; import { Box } from "@mui/material"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton"; @@ -11,17 +12,23 @@ import { WorkOrderViewAccordions } from "./_components/view/work-order-view-acco import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; +import { WorkOrderCompleteDialog } from "./_components/list/work-order-complete-dialog"; import { detailToClosabilityInput, 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() { const { id } = useParams(); const workOrderId = id ?? ""; const view = useWorkOrderViewPage(workOrderId); + const [confirmComplete, setConfirmComplete] = useState(false); if (view.isLoading && !view.wo) { return ; @@ -39,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 && @@ -48,6 +57,10 @@ export default function WorkOrderViewPage() { ) { return; } + if (isCompletionStatus) { + setConfirmComplete(true); + return; + } view.statusMutation.mutate(status as WorkOrderStatus); }; @@ -61,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} /> @@ -110,12 +132,24 @@ export default function WorkOrderViewPage() { loading={view.vendorEditorLoading} error={view.vendorEditorError} isSaving={view.vendorMutation.isPending} + viewOnly={isLocked} onClose={() => { if (!view.vendorMutation.isPending) view.setVendorEditor(null); }} onSave={view.saveVendorEditor} onEditorChange={view.setVendorEditor} /> + + setConfirmComplete(false)} + onKeepOpen={() => setConfirmComplete(false)} + onConfirm={() => { + view.statusMutation.mutate("Done"); + setConfirmComplete(false); + }} + /> ); } diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx index 82b03b26..cc623902 100644 --- a/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx +++ b/src/app/(protected)/workorders/_components/detail/slide-over-footer.tsx @@ -4,6 +4,7 @@ import { Text } from "@/components/ui/text"; type SlideOverFooterProps = { isCancelled: boolean; + isCompleted?: boolean; editing: boolean; isDirty: boolean; showUnsaved: boolean; @@ -19,6 +20,7 @@ type SlideOverFooterProps = { export function SlideOverFooter({ isCancelled, + isCompleted = false, editing, isDirty, showUnsaved, @@ -31,6 +33,27 @@ export function SlideOverFooter({ onDiscard, onKeepEditing, }: SlideOverFooterProps) { + if (isCancelled) { + return ( +
+ + + This work order has been cancelled and is read-only. + +
+ ); + } + + if (isCompleted) { + return null; + } + if (showUnsaved) { return (
- - - This work order has been cancelled and is read-only. - -
- ); - } - return (
{source.woNumber || "—"} - - {source.site || "—"} - + {isCompleted ? ( + undefined} + /> + ) : ( + + {source.site || "—"} + + )} {source.type ? ( @@ -61,7 +94,17 @@ export function SlideOverInfoTabDisplayView({ {apptDisplay} - {source.pm ? ( + {isCompleted ? ( + undefined} + /> + ) : source.pm ? ( {source.pm} @@ -71,7 +114,19 @@ export function SlideOverInfoTabDisplayView({ )} - + {isCompleted ? ( + undefined} + /> + ) : ( + + )} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 8b92c86f..6cbd8006 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -27,6 +27,7 @@ type SlideOverInfoTabEditViewProps = { allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; + onRequestComplete?: () => void; }; export function SlideOverInfoTabEditView({ @@ -38,6 +39,7 @@ export function SlideOverInfoTabEditView({ allOrders, onDraftChange, onDuplicateFound, + onRequestComplete, }: SlideOverInfoTabEditViewProps) { return (
@@ -149,6 +151,7 @@ export function SlideOverInfoTabEditView({ wo={draft} onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })} onOpenSlideOver={() => {}} + onRequestComplete={onRequestComplete} /> 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 5d109b07..a9a186f2 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 @@ -12,6 +12,7 @@ type SlideOverInfoTabProps = { draft: WorkOrderTableRow; editing: boolean; isCancelled: boolean; + isCompleted?: boolean; users: WorkOrderUser[]; sites: LocationOption[]; vendors: VendorDropdownItem[]; @@ -19,6 +20,7 @@ type SlideOverInfoTabProps = { allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; + onRequestComplete?: () => void; }; export function SlideOverInfoTab({ @@ -26,6 +28,7 @@ export function SlideOverInfoTab({ draft, editing, isCancelled, + isCompleted = false, users, sites, vendors, @@ -33,8 +36,9 @@ export function SlideOverInfoTab({ allOrders, onDraftChange, onDuplicateFound, + onRequestComplete, }: SlideOverInfoTabProps) { - const isEditing = editing && !isCancelled; + const isEditing = editing && !isCancelled && !isCompleted; const source = isEditing ? draft : row; const dispatcher = users.find((u) => String(u.id) === source.dispatcherId); @@ -49,6 +53,7 @@ export function SlideOverInfoTab({ allOrders={allOrders} onDraftChange={onDraftChange} onDuplicateFound={onDuplicateFound} + onRequestComplete={onRequestComplete} /> ); } @@ -57,7 +62,11 @@ export function SlideOverInfoTab({ ); } 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 ec74d498..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 @@ -7,9 +7,17 @@ import { buildSlideOverPatch, isSlideOverDraftDirty, } from "@/domain/work-orders/utils/slide-over-draft"; +import { + isWorkOrderCoreLocked, + isWorkOrderFullyLocked, +} from "@/domain/work-orders/utils/work-order-lock"; import type { SlideOverSaveOptions } from "./work-order-slide-over"; import type { SlideOverTab } from "./use-work-order-slide-over"; +function isSlideOverInfoLocked(status: string | null | undefined): boolean { + return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status); +} + type UseSlideOverEditStateArgs = { row: WorkOrderTableRow | null; editMode: boolean; @@ -49,7 +57,7 @@ export function useSlideOverEditState({ } setDraft(row); setBaseline(row); - setEditing(editMode && (row.status as string) !== "Canceled"); + setEditing(editMode && !isSlideOverInfoLocked(row.status)); setTab("info"); setShowUnsaved(false); setUnsavedIntent(null); @@ -57,6 +65,21 @@ export function useSlideOverEditState({ // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate }, [row?.id, editMode]); + const lockStatus = infoSource?.status ?? row?.status; + const isInfoLocked = isSlideOverInfoLocked(lockStatus); + + useEffect(() => { + if (!isInfoLocked) return; + setEditing(false); + setShowUnsaved(false); + setUnsavedIntent(null); + const source = infoSource ?? row; + if (source) { + setDraft(source); + setBaseline(source); + } + }, [isInfoLocked, infoSource, row]); + useEffect(() => { if (!infoSource) return; if (!editing) { @@ -84,6 +107,7 @@ export function useSlideOverEditState({ const commit = () => { if (!row || !activeDraft || !dirtyBaseline) return; + if (isSlideOverInfoLocked(infoSource?.status ?? row.status)) return; const patch = buildSlideOverPatch(activeDraft, dirtyBaseline); if (Object.keys(patch).length === 0) { setEditing(false); @@ -138,7 +162,7 @@ export function useSlideOverEditState({ }; const startEditing = () => { - if (!infoSource) 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 9120bb5f..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 @@ -13,11 +13,18 @@ import { useUploadCompletionDoc, useUploadWorkOrderMedia, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { + canMutateWorkOrderComments, + canMutateWorkOrderExtraDocs, + isWorkOrderCompleted, + isWorkOrderFullyLocked, +} from "@/domain/work-orders/utils/work-order-lock"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; 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"; @@ -30,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, @@ -54,13 +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 = (infoSource?.status as string) === "Canceled"; - const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status); + const { detailForRow, infoSource } = resolveSlideOverSource(detail, row); + const readFlags = slideOverReadFlags(infoSource?.status); const { completionMedia, @@ -92,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, @@ -138,8 +188,7 @@ export function useWorkOrderSlideOver({ setTab, detailForRow, infoSource, - isCancelled, - isDocumentsReadOnly, + ...readFlags, completionMedia, extrasUploads, mediaPending, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 7b174a87..222ed0d6 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -26,6 +26,7 @@ type WorkOrderSlideOverTabsProps = { allOrders: WorkOrderTableRow[]; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onOpenCompletionDoc: () => void; + onRequestComplete?: () => void; }; export function WorkOrderSlideOverTabs({ @@ -39,6 +40,7 @@ export function WorkOrderSlideOverTabs({ allOrders, onDuplicateFound, onOpenCompletionDoc, + onRequestComplete, }: WorkOrderSlideOverTabsProps) { const hasError = Boolean(state.error) && !state.detailForRow; const showContent = Boolean(state.detailForRow || !state.isLoading); @@ -52,6 +54,7 @@ export function WorkOrderSlideOverTabs({ draft={activeDraft} editing={state.editing && !saving} isCancelled={state.isCancelled} + isCompleted={state.isCompleted} users={users} sites={sites} vendors={vendors} @@ -59,6 +62,7 @@ export function WorkOrderSlideOverTabs({ allOrders={allOrders} onDraftChange={state.handleDraftChange} onDuplicateFound={onDuplicateFound} + onRequestComplete={onRequestComplete} /> )} @@ -74,7 +78,7 @@ export function WorkOrderSlideOverTabs({ {state.detailForRow != null && ( { void state.uploadExtraFiles(files); diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 64d2e8f9..c240c19e 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -31,6 +31,7 @@ type WorkOrderSlideOverProps = { onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onOpenCompletionDoc: (row: WorkOrderTableRow) => void; onCancelWO: (row: WorkOrderTableRow) => void; + onRequestComplete?: (row: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onClose: () => void; saving?: boolean; @@ -47,6 +48,7 @@ export function WorkOrderSlideOver({ onSave, onOpenCompletionDoc, onCancelWO, + onRequestComplete, onDuplicateFound, onClose, saving, @@ -85,10 +87,12 @@ export function WorkOrderSlideOver({ allOrders={allOrders} onDuplicateFound={onDuplicateFound} onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)} + onRequestComplete={() => onRequestComplete?.(infoSource)} /> void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; + locked?: boolean; }; function CounterPill({ label, count }: { label: string; count: number }) { @@ -74,6 +75,7 @@ export function EditableWONumberCell({ allOrders, onSave, onDuplicateFound, + locked = false, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -131,6 +133,29 @@ export function EditableWONumberCell({ const invalid = editing && draft.length > 0 && draft.length !== 11; + if (locked) { + return ( +
+ + {value ? : "-"} + + + + +
+ ); + } + return (
{editing ? ( diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index 58dbf80f..bec126e3 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -22,6 +22,7 @@ type PMTypeCellProps = { extraServices?: string[]; woNumber?: string; onSaveDetails?: (details: ServiceNotesDetails) => void; + viewOnly?: boolean; }; export function PMTypeCell({ @@ -33,6 +34,7 @@ export function PMTypeCell({ extraServices = [], woNumber, onSaveDetails, + viewOnly = false, }: PMTypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [search, setSearch] = useState(""); @@ -52,7 +54,7 @@ export function PMTypeCell({ }; const modal = - onSaveDetails != null ? ( + onSaveDetails != null || viewOnly ? ( undefined)} /> ) : null; + if (viewOnly) { + return ( + <> + + {modal} + + ); + } + if (hasDetails && onSaveDetails) { return ( <> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index c0166809..b6218886 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -19,6 +19,7 @@ type ServiceNotesDialogProps = { serviceNotes: string; woNumber?: string; pmTypes: string[]; + viewOnly?: boolean; onSave: (details: ServiceNotesDetails) => void; }; @@ -30,6 +31,7 @@ export function ServiceNotesDialog({ serviceNotes, woNumber, pmTypes, + viewOnly = false, onSave, }: ServiceNotesDialogProps) { const [sel, setSel] = useState([]); @@ -76,38 +78,52 @@ export function ServiceNotesDialog({ onClick={(e) => e.stopPropagation()} style={{ paddingTop: 8 }} > - +
+ +
- - + {viewOnly ? ( + + ) : ( + <> + + + + )}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx index 104286d5..4c40ae06 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -21,6 +21,7 @@ type SiteCellProps = { q?: string; /** Inline create: editable POC + Site/POC required to confirm the modal. */ createMode?: boolean; + viewOnly?: boolean; onSave: (patch: SitePatch) => void; }; @@ -37,6 +38,7 @@ export function SiteCell({ sites, q, createMode = false, + viewOnly = false, onSave, }: SiteCellProps) { const [open, setOpen] = useState(false); @@ -99,6 +101,7 @@ export function SiteCell({ additionalContacts={additionalContacts} sites={sites} createMode={createMode} + viewOnly={viewOnly} onSave={onSave} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx index ed269746..8492225e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx @@ -3,9 +3,20 @@ import { Button } from "@/components/ui/button"; type SiteDialogFooterProps = { onClose: () => void; onConfirm: () => void; + viewOnly?: boolean; }; -export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) { +export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) { + if (viewOnly) { + return ( +
+ +
+ ); + } + return (
+
+ ); + } + return (
+ + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index 9e975fbd..8b150532 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -3,6 +3,7 @@ import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/det import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog"; +import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog"; import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays"; import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard"; import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; @@ -58,6 +59,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps onSave={(id, patch, options) => page.patchField(id, patch, options)} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)} onCancelWO={page.setConfirmCancel} + onRequestComplete={page.setConfirmComplete} onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} saving={page.isPatchPending} onClose={page.handleCloseSlideOver} @@ -71,6 +73,14 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps onConfirm={page.handleConfirmCancel} /> + page.setConfirmComplete(null)} + onConfirm={page.handleConfirmComplete} + /> + {page.activeMediaRow != null && ( 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-actions.tsx b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-actions.tsx new file mode 100644 index 00000000..3c8da517 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-actions.tsx @@ -0,0 +1,40 @@ +import { Button } from "@mui/material"; +import type { VendorEditorState } from "./work-order-vendor-editor-types"; +import { isVendorEditorValid } from "./work-order-vendor-editor-utils"; + +type WorkOrderVendorEditorActionsProps = { + viewOnly: boolean; + editor: VendorEditorState | null; + loading: boolean; + isSaving: boolean; + onClose: () => void; + onSave: () => void; +}; + +export function WorkOrderVendorEditorActions({ + viewOnly, + editor, + loading, + isSaving, + onClose, + onSave, +}: WorkOrderVendorEditorActionsProps) { + if (viewOnly) { + return ; + } + + return ( + <> + + + + ); +} 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..78439708 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 @@ -1,24 +1,24 @@ import { Alert, Box, - Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Stack, - TextField, Typography, } from "@mui/material"; import type { VendorEditorState } from "./work-order-vendor-editor-types"; -import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils"; +import { WorkOrderVendorEditorActions } from "./work-order-vendor-editor-actions"; +import { WorkOrderVendorEditorFields } from "./work-order-vendor-editor-fields"; type WorkOrderVendorEditorDialogProps = { editor: VendorEditorState | null; loading: boolean; error: string; isSaving: boolean; + viewOnly?: boolean; onClose: () => void; onSave: () => void; onEditorChange: ( @@ -31,13 +31,14 @@ export function WorkOrderVendorEditorDialog({ loading, error, isSaving, + viewOnly = false, onClose, onSave, onEditorChange, }: WorkOrderVendorEditorDialogProps) { return ( - Edit assigned vendor + {viewOnly ? "Assigned vendor" : "Edit assigned vendor"} @@ -49,65 +50,23 @@ export function WorkOrderVendorEditorDialog({ ) : ( - <> - - onEditorChange((current) => - current ? { ...current, contactName: event.target.value } : current, - ) - } - slotProps={{ htmlInput: { maxLength: 200 } }} - /> - - onEditorChange((current) => - current ? { ...current, phone: formatPhoneMask(event.target.value) } : current, - ) - } - error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))} - helperText="Use (XXX) XXX-XXXX" - /> - - onEditorChange((current) => - current ? { ...current, email: event.target.value } : current, - ) - } - /> - - onEditorChange((current) => - current ? { ...current, notes: event.target.value } : current, - ) - } - slotProps={{ htmlInput: { maxLength: 2000 } }} - /> - + )} - - + ); diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-fields.tsx b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-fields.tsx new file mode 100644 index 00000000..3366ed82 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-fields.tsx @@ -0,0 +1,71 @@ +import { TextField } from "@mui/material"; +import type { VendorEditorState } from "./work-order-vendor-editor-types"; +import { formatPhoneMask } from "./work-order-vendor-editor-utils"; + +type VendorEditorChange = ( + updater: (current: VendorEditorState | null) => VendorEditorState | null, +) => void; + +type WorkOrderVendorEditorFieldsProps = { + editor: VendorEditorState | null; + disabled: boolean; + onEditorChange: VendorEditorChange; +}; + +export function WorkOrderVendorEditorFields({ + editor, + disabled, + onEditorChange, +}: WorkOrderVendorEditorFieldsProps) { + return ( + <> + + onEditorChange((current) => + current ? { ...current, contactName: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + + onEditorChange((current) => + current ? { ...current, phone: formatPhoneMask(event.target.value) } : current, + ) + } + error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))} + helperText="Use (XXX) XXX-XXXX" + /> + + onEditorChange((current) => + current ? { ...current, email: event.target.value } : current, + ) + } + /> + + onEditorChange((current) => + current ? { ...current, notes: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + ); +} 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 e28272e3..f009b9d7 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 @@ -1,3 +1,4 @@ +import { useState } from "react"; 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"; @@ -19,6 +20,7 @@ import { wizardDraftToClosabilityInput, } from "@/domain/work-orders/utils/wo-closability"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog"; import { DLG_INPUT_CLS, DLG_INPUT_STYLE, @@ -115,6 +117,62 @@ function WizardVendorDetail({ ); } +type WizardApptTimeFieldsProps = Pick; + +function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFieldsProps) { + 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; @@ -134,6 +192,7 @@ export function WizardStepVendorTime({ onUpdate, onPatch, }: WizardStepVendorTimeProps) { + const [confirmComplete, setConfirmComplete] = useState(false); 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; @@ -204,55 +263,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 @@ -264,10 +275,24 @@ export function WizardStepVendorTime({ options={buildWizardStatusOptions(closabilityGaps)} onPick={(status) => { if (status === "Completed" && closabilityGaps.length > 0) return; + if (status === "Completed" && draft.status !== "Completed") { + setConfirmComplete(true); + return; + } onUpdate("status", status as WizardWOStatus); }} />
+ setConfirmComplete(false)} + onKeepOpen={() => setConfirmComplete(false)} + onConfirm={() => { + onUpdate("status", "Completed"); + setConfirmComplete(false); + }} + />
); } 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/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index cfcbe7b6..4ddf763e 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -17,6 +17,7 @@ export function useWorkOrdersListPage() { const [slideOverRow, setSlideOverRow] = useState(null); const [slideOverEdit, setSlideOverEdit] = useState(false); const [confirmCancel, setConfirmCancel] = useState(null); + const [confirmComplete, setConfirmComplete] = useState(null); const [docRow, setDocRow] = useState(null); const [mediaRow, setMediaRow] = useState(null); const [upliftsRow, setUpliftsRow] = useState(null); @@ -86,7 +87,14 @@ export function useWorkOrdersListPage() { handleCloseSlideOver(); }; + const handleConfirmComplete = () => { + if (!confirmComplete) return; + patchField(confirmComplete.id, { status: "Completed" }); + setConfirmComplete(null); + }; + const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null)); + const completeDialogClose = guardDialogClose(isPatchPending, () => setConfirmComplete(null)); const saveCompletionDoc = async ( row: WorkOrderTableRow, @@ -109,6 +117,8 @@ export function useWorkOrdersListPage() { slideOverEdit, confirmCancel, setConfirmCancel, + confirmComplete, + setConfirmComplete, docRow, setDocRow, mediaRow, @@ -133,7 +143,9 @@ export function useWorkOrdersListPage() { handleOpenSlideOver, handleCloseSlideOver, handleConfirmCancel, + handleConfirmComplete, cancelDialogClose, + completeDialogClose, saveCompletionDoc, }; } diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index a48da809..5bd8dbb1 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -98,6 +98,7 @@ export default function WorkOrdersListPage() { weekMonday={filters.weekMonday} onNavigateToDay={filters.navigateToDay} onOpenSlideOver={page.handleOpenSlideOver} + onRequestComplete={page.setConfirmComplete} onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)} onOpenMedia={page.setMediaRow} onOpenUplifts={page.setUpliftsRow} diff --git a/src/domain/work-orders/utils/work-order-lock.ts b/src/domain/work-orders/utils/work-order-lock.ts new file mode 100644 index 00000000..19af3d42 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-lock.ts @@ -0,0 +1,52 @@ +export function isWorkOrderCanceled(status: string | null | undefined): boolean { + return status === "Canceled" || status === "Cancelled"; +} + +export function isWorkOrderCompleted(status: string | null | undefined): boolean { + return status === "Completed" || status === "Done"; +} + +export function isWorkOrderCoreLocked(status: string | null | undefined): boolean { + return isWorkOrderCompleted(status); +} + +export function isWorkOrderFullyLocked(status: string | null | undefined): boolean { + return isWorkOrderCanceled(status); +} + +export function canMutateWorkOrderFlag(status: string | null | undefined): boolean { + return !isWorkOrderFullyLocked(status); +} + +export function canMutateWorkOrderComments(status: string | null | undefined): boolean { + return !isWorkOrderFullyLocked(status); +} + +export function canMutateWorkOrderExtraDocs(status: string | null | undefined): boolean { + return !isWorkOrderFullyLocked(status); +} + +const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]); + +/** + * Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use + * dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`). + */ +export function canApplyWorkOrderTablePatch( + status: string | null | undefined, + patch: Record, +): boolean { + if (isWorkOrderFullyLocked(status)) { + return false; + } + if (!isWorkOrderCoreLocked(status)) { + return true; + } + const keys = Object.keys(patch); + return keys.length > 0 && keys.every((key) => COMPLETED_BOARD_PATCH_KEYS.has(key)); +} + +/** Completion document / completion-media surface — not Extra Docs or comments. */ +export function isWorkOrderCompletionSurfaceReadOnly(status: string | null | undefined): boolean { + return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status); +} diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index d79f45be..58cc358a 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -29,9 +29,11 @@ export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): str return `${Math.round(maxBytes / (1024 * 1024))} MB`; } -/** Backend IsReadOnly: Canceled or Completed. */ +import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; + +/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */ export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean { - return status === "Canceled" || status === "Completed"; + return isWorkOrderCompletionSurfaceReadOnly(status); } export function canDeleteWorkOrderMedia(media: { diff --git a/src/test/app/(protected)/workorders/completed-lock-view-only-dialogs.test.tsx b/src/test/app/(protected)/workorders/completed-lock-view-only-dialogs.test.tsx new file mode 100644 index 00000000..71c32f3c --- /dev/null +++ b/src/test/app/(protected)/workorders/completed-lock-view-only-dialogs.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("Completed view-only dialogs", () => { + it("hides Save on a view-only site dialog", () => { + const onSave = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^close$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("hides Save on a view-only service notes dialog", () => { + const onSave = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + }); +}); 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 new file mode 100644 index 00000000..1e4b45d7 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-completed-comments-extras.test.tsx @@ -0,0 +1,97 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab"; +import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; +import { + canMutateWorkOrderComments, + canMutateWorkOrderExtraDocs, +} from "@/domain/work-orders/utils/work-order-lock"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("Completed comments and extra docs remain writable", () => { + it("shows the comment composer when status is Completed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByPlaceholderText("Add comment…")).toBeInTheDocument(); + 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( + , + { withAuth: false }, + ); + + expect(screen.queryByPlaceholderText("Add comment…")).not.toBeInTheDocument(); + }); + + it("shows extra-docs upload when status is Completed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText(/upload documents/i)).toBeInTheDocument(); + }); + + it("hides extra-docs upload when status is Canceled", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/upload documents/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx b/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx new file mode 100644 index 00000000..348fbf48 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx @@ -0,0 +1,70 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer"; +import { renderWithProviders } from "@/test/test-utils"; + +const footerHandlers = { + onCancelWO: vi.fn(), + onCancelEdit: vi.fn(), + onClose: vi.fn(), + onEdit: vi.fn(), + onSave: vi.fn(), + onDiscard: vi.fn(), + onKeepEditing: vi.fn(), +}; + +describe("SlideOverFooter completed lock", () => { + it("hides Edit and Cancel WO when the work order is completed", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByRole("button", { name: /edit wo/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument(); + }); + + it("hides Cancel WO and Save when completed while still in edit mode", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + }); + + it("hides the unsaved Save bar when the work order is completed", () => { + const { container } = renderWithProviders( + , + { withAuth: false }, + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByText(/unsaved changes/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index e6e50cfe..dd2f9d58 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -106,7 +106,7 @@ describe("StatusCell pending uplift closability", () => { ); }); - it("allows Completed when no pending uplift", () => { + it("does not patch Completed when confirmation callback is omitted", () => { const onChangeStatus = vi.fn(); renderWithProviders( @@ -117,7 +117,28 @@ describe("StatusCell pending uplift closability", () => { fireEvent.click(screen.getByRole("button", { name: /in progress/i })); fireEvent.click(screen.getByRole("button", { name: /completed/i })); - expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + + it("allows Completed when no pending uplift", () => { + const onChangeStatus = vi.fn(); + const onRequestComplete = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onRequestComplete).toHaveBeenCalled(); + expect(onChangeStatus).not.toHaveBeenCalled(); }); it("disables Completed when company is missing", () => { @@ -143,12 +164,14 @@ describe("StatusCell pending uplift closability", () => { it("allows Completed when vendorId is set even if company label is empty", () => { const onChangeStatus = vi.fn(); + const onRequestComplete = vi.fn(); renderWithProviders( , { withAuth: false }, ); @@ -156,17 +179,20 @@ describe("StatusCell pending uplift closability", () => { fireEvent.click(screen.getByRole("button", { name: /in progress/i })); fireEvent.click(screen.getByRole("button", { name: /completed/i })); - expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + expect(onRequestComplete).toHaveBeenCalled(); + expect(onChangeStatus).not.toHaveBeenCalled(); }); it("allows Completed when vendorId has an empty company label and a live dispatch status", () => { const onChangeStatus = vi.fn(); + const onRequestComplete = vi.fn(); renderWithProviders( , { withAuth: false }, ); @@ -174,7 +200,8 @@ describe("StatusCell pending uplift closability", () => { fireEvent.click(screen.getByRole("button", { name: /in progress/i })); fireEvent.click(screen.getByRole("button", { name: /completed/i })); - expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + expect(onRequestComplete).toHaveBeenCalled(); + expect(onChangeStatus).not.toHaveBeenCalled(); }); it.each(["Cancelled", "Canceled", "Refused"] as const)( @@ -232,6 +259,7 @@ describe("StatusCell pending uplift closability", () => { it("allows Completed after a vendor patch clears leftover refused status", () => { const onChangeStatus = vi.fn(); + const onRequestComplete = vi.fn(); renderWithProviders( { })} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} + onRequestComplete={onRequestComplete} />, { withAuth: false }, ); @@ -250,24 +279,47 @@ describe("StatusCell pending uplift closability", () => { fireEvent.click(screen.getByRole("button", { name: /in progress/i })); fireEvent.click(screen.getByRole("button", { name: /completed/i })); - expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + expect(onRequestComplete).toHaveBeenCalled(); + expect(onChangeStatus).not.toHaveBeenCalled(); }); - it("allows Completed when technician is empty if company is set", () => { + 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(); + + renderWithProviders( + , + { withAuth: false }, + ); + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); fireEvent.click(screen.getByRole("button", { name: /completed/i })); - expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + expect(onRequestComplete).toHaveBeenCalled(); + expect(onChangeStatus).not.toHaveBeenCalled(); }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx new file mode 100644 index 00000000..bde4cad9 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -0,0 +1,96 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +function baseRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 100, + woNumber: "100", + site: "SITE", + type: "Emergency", + severity: null, + dispatcherId: "d1", + dispatcherName: "Alex", + scheduledOn: "2026-07-16", + completedDate: "", + targetWeek: "", + dayGroup: "2026-07-16", + dayLabel: "Thu", + location: "Main", + locationId: 5, + pm: "", + serviceNotes: "", + extraServices: [], + vendorId: "v1", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "1", + dispatchRowVersion: "", + flagColor: null, + isAddOn: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +describe("useSlideOverEditState completed lock", () => { + it("leaves edit mode and refuses commit when the same work order becomes completed", () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + const setTab = vi.fn(); + const open = baseRow(); + + const { result, rerender } = renderHook( + ({ row, infoSource }) => + useSlideOverEditState({ + row, + editMode: true, + infoSource, + onSave, + onClose, + closeDisabled: false, + setTab, + }), + { initialProps: { row: open, infoSource: open } }, + ); + + expect(result.current.editing).toBe(true); + + act(() => { + result.current.handleDraftChange({ woNumber: "WO-LOCKED" }); + }); + + const completed = baseRow({ status: "Completed", woNumber: "100" }); + rerender({ row: completed, infoSource: completed }); + + expect(result.current.editing).toBe(false); + + act(() => { + result.current.commit(); + }); + + expect(onSave).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 00000000..c49d8001 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -0,0 +1,136 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 9, + woNumber: "24818000001", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "2026-07-20", + targetWeek: "2026-07-15", + dayGroup: "07/15", + dayLabel: "Tue", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "09:00 – 10:00", + status: "Completed", + docStatus: "Yes", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "2026-07-22", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 1, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + }; +} + +const handlers: WoTableRowHandlers = { + onRowClick: vi.fn(), + onView: vi.fn(), + onEdit: vi.fn(), + onOpenMedia: vi.fn(), + onPatch: vi.fn(), + onScheduleDate: vi.fn(), + onScheduleWeek: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenCompDoc: vi.fn(), + onOpenUplifts: vi.fn(), + onReorder: vi.fn(), + onDragStart: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + onDragEnd: vi.fn(), +}; + +describe("WoTableRow completed selective lock", () => { + it("keeps Flag enabled and hides Edit on a Completed row", () => { + const row = makeRow(); + renderWithProviders( + + + + +
, + { withAuth: false }, + ); + + expect(screen.getByTitle("Add color flag")).toBeEnabled(); + fireEvent.click(screen.getByTitle("Add color flag")); + expect(screen.getByText("Clear flag")).toBeInTheDocument(); + 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-complete-dialog.test.tsx b/src/test/app/(protected)/workorders/work-order-complete-dialog.test.tsx new file mode 100644 index 00000000..0bf323dd --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-complete-dialog.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("WorkOrderCompleteDialog", () => { + it("confirms or dismisses without side effects until Mark as Complete", () => { + const onConfirm = vi.fn(); + const onKeepOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Mark as Complete?")).toBeInTheDocument(); + expect( + screen.getByText("Once marked as complete, this work order cannot be edited anymore."), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(onKeepOpen).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("calls onConfirm from Mark as Complete", () => { + const onConfirm = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /mark as complete/i })); + expect(onConfirm).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(); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-lock.test.ts b/src/test/domain/work-orders/utils/work-order-lock.test.ts new file mode 100644 index 00000000..f65f3a30 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-lock.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + canApplyWorkOrderTablePatch, + canMutateWorkOrderComments, + canMutateWorkOrderExtraDocs, + canMutateWorkOrderFlag, + isWorkOrderCompletionSurfaceReadOnly, + isWorkOrderCoreLocked, + isWorkOrderFullyLocked, +} from "@/domain/work-orders/utils/work-order-lock"; + +describe("work-order-lock", () => { + it("locks core fields only when Completed", () => { + expect(isWorkOrderCoreLocked("Completed")).toBe(true); + expect(isWorkOrderCoreLocked("Done")).toBe(true); + expect(isWorkOrderCoreLocked("Scheduled")).toBe(false); + expect(isWorkOrderCoreLocked("Canceled")).toBe(false); + }); + + it("fully locks only canceled statuses", () => { + expect(isWorkOrderFullyLocked("Canceled")).toBe(true); + expect(isWorkOrderFullyLocked("Cancelled")).toBe(true); + expect(isWorkOrderFullyLocked("Completed")).toBe(false); + }); + + it("keeps flag comments and extra docs writable on Completed", () => { + expect(canMutateWorkOrderFlag("Completed")).toBe(true); + expect(canMutateWorkOrderComments("Completed")).toBe(true); + expect(canMutateWorkOrderExtraDocs("Completed")).toBe(true); + expect(canMutateWorkOrderFlag("Canceled")).toBe(false); + expect(canMutateWorkOrderComments("Canceled")).toBe(false); + expect(canMutateWorkOrderExtraDocs("Canceled")).toBe(false); + }); + + it("keeps the completion document surface read-only after complete", () => { + expect(isWorkOrderCompletionSurfaceReadOnly("Completed")).toBe(true); + expect(isWorkOrderCompletionSurfaceReadOnly("Canceled")).toBe(true); + expect(isWorkOrderCompletionSurfaceReadOnly("Scheduled")).toBe(false); + }); + + it("allows only flagColor board patches on Completed", () => { + expect(canApplyWorkOrderTablePatch("Completed", { flagColor: "red" })).toBe(true); + expect(canApplyWorkOrderTablePatch("Completed", { dispatcherId: "u2" })).toBe(false); + expect(canApplyWorkOrderTablePatch("Completed", { status: "Scheduled" })).toBe(false); + expect(canApplyWorkOrderTablePatch("Completed", { site: "BK1" })).toBe(false); + expect(canApplyWorkOrderTablePatch("Scheduled", { dispatcherId: "u2" })).toBe(true); + expect(canApplyWorkOrderTablePatch("Canceled", { flagColor: "red" })).toBe(false); + }); +});