diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index 8c63b1b0..a81d6f04 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,6 +12,7 @@ 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, @@ -22,6 +24,7 @@ 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 ; @@ -48,6 +51,10 @@ export default function WorkOrderViewPage() { ) { return; } + if (isCompletionStatus) { + setConfirmComplete(true); + return; + } view.statusMutation.mutate(status as WorkOrderStatus); }; @@ -116,6 +123,17 @@ export default function WorkOrderViewPage() { 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..6792121e 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, @@ -85,6 +87,10 @@ export function SlideOverFooter({ ); } + if (isCompleted && !editing) { + return null; + } + 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..f4a581b1 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,6 +7,10 @@ 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"; @@ -49,7 +53,9 @@ export function useSlideOverEditState({ } setDraft(row); setBaseline(row); - setEditing(editMode && (row.status as string) !== "Canceled"); + setEditing( + editMode && !isWorkOrderFullyLocked(row.status) && !isWorkOrderCoreLocked(row.status), + ); setTab("info"); setShowUnsaved(false); setUnsavedIntent(null); @@ -139,6 +145,9 @@ export function useSlideOverEditState({ const startEditing = () => { if (!infoSource) return; + if (isWorkOrderFullyLocked(infoSource.status) || isWorkOrderCoreLocked(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..09aaa9fc 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,6 +13,12 @@ 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"; @@ -59,8 +65,11 @@ export function useWorkOrderSlideOver({ 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 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 { completionMedia, @@ -139,7 +148,10 @@ export function useWorkOrderSlideOver({ detailForRow, infoSource, isCancelled, + isCompleted, isDocumentsReadOnly, + isCommentsReadOnly, + isExtrasReadOnly, 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 && ( String(v.id) === draft.vendorId); const statusValue = (draft.status || "Incomplete") as WizardWOStatus; const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; @@ -264,10 +267,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-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..96e99ae9 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-lock.ts @@ -0,0 +1,53 @@ +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 SH-115. 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, + 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..981583cb --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-completed-comments-extras.test.tsx @@ -0,0 +1,70 @@ +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("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..8b58ebbe --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-footer-completed.test.tsx @@ -0,0 +1,30 @@ +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"; + +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(); + }); +}); 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..0afe8821 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,17 +279,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 technician is empty if company is set", () => { const onChangeStatus = vi.fn(); + const onRequestComplete = vi.fn(); renderWithProviders( , { withAuth: false }, ); @@ -268,6 +300,7 @@ 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(); }); }); 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..df84f7f2 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-completed-lock.test.tsx @@ -0,0 +1,107 @@ +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(); + }); +}); 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/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); + }); +});