) => void;
};
@@ -33,6 +35,8 @@ export function WoTableRowCells({
forceScheduleHighlight,
forceDueDateHighlight,
mutationCellStyle,
+ viewOnly = false,
+ canceledLockStyle,
handlers,
onPatchRow,
}: WoTableRowCellsProps) {
@@ -40,7 +44,7 @@ export function WoTableRowCells({
<>
e.stopPropagation()}
>
onPatchRow({
site: p.site,
@@ -79,6 +84,7 @@ export function WoTableRowCells({
q={search}
currentId={row.id}
allOrders={allRows}
+ locked={viewOnly}
onSave={(v) => onPatchRow({ woNumber: v })}
onDuplicateFound={handlers.onDuplicateFound}
/>
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
index 26773141..3d3e3fc6 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx
@@ -19,6 +19,8 @@ type WoTableRowServiceCellsProps = {
pmTypes: string[];
search?: string;
mutationCellStyle?: CSSProperties;
+ viewOnly?: boolean;
+ canceledLockStyle?: CSSProperties;
handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial) => void;
};
@@ -29,6 +31,8 @@ export function WoTableRowServiceCells({
pmTypes,
search,
mutationCellStyle,
+ viewOnly = false,
+ canceledLockStyle,
handlers,
onPatchRow,
}: WoTableRowServiceCellsProps) {
@@ -36,7 +40,7 @@ export function WoTableRowServiceCells({
<>
| e.stopPropagation()}
>
onPatchRow({ pm })}
onSaveDetails={(d) =>
onPatchRow({
@@ -58,7 +63,7 @@ export function WoTableRowServiceCells({
|
e.stopPropagation()}
>
onPatchRow(toVendorTablePatch(p))}
/>
|
@@ -85,9 +91,11 @@ export function WoTableRowServiceCells({
>
onPatchRow({ status })}
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
+ onRequestComplete={() => handlers.onRequestComplete?.(row)}
/>
|
e.stopPropagation()}>
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx
index 67e06189..40e7aa88 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx
@@ -6,6 +6,12 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
+import {
+ canApplyWorkOrderTablePatch,
+ canMutateWorkOrderFlag,
+ isWorkOrderCoreLocked,
+ isWorkOrderFullyLocked,
+} from "@/domain/work-orders/utils/work-order-lock";
import { FlagCell } from "./cells/flag-cell";
import { WoTableRowActions } from "./wo-table-row-actions";
import { WoTableRowCells } from "./wo-table-row-cells";
@@ -28,6 +34,7 @@ export type WoTableRowHandlers = {
onScheduleWeek: (id: string | number, week: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenSlideOver: (row: WorkOrderTableRow) => void;
+ onRequestComplete?: (row: WorkOrderTableRow) => void;
onPastDueAttempt?: (id: string | number) => void;
onReorder: (dragId: string | number, overId: string | number) => void;
onDragStart: (id: string | number) => void;
@@ -71,15 +78,33 @@ export function WoTableRow({
}: WoTableRowProps) {
const isDragging = dragViz.dragId === row.id;
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
- const isCanceled = (row.status as string) === "Canceled";
+ const isCanceled = isWorkOrderFullyLocked(row.status);
+ const isCoreLocked = isWorkOrderCoreLocked(row.status);
+ const closedForEdit = isCanceled || isCoreLocked;
+
+ const guardedHandlers: WoTableRowHandlers = {
+ ...handlers,
+ onPatch: (id, patch) => {
+ if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
+ handlers.onPatch(id, patch);
+ },
+ onScheduleDate: (id, date) => {
+ if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
+ handlers.onScheduleDate(id, date);
+ },
+ onScheduleWeek: (id, week) => {
+ if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
+ handlers.onScheduleWeek(id, week);
+ },
+ };
const patchRow = (patch: Partial) => {
- if (isCanceled) return;
+ if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(row.id, patch);
};
- /** Block mutation controls on canceled rows while keeping row/View clickable. */
- const mutationCellStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
+ const coreLockStyle = closedForEdit ? MUTATION_CELL_STYLE : undefined;
+ const canceledLockStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
return (
| e.stopPropagation()}
>
|
- e.stopPropagation()}
- >
+ | e.stopPropagation()}>
handlers.onPatch(row.id, { flagColor: c })}
+ disabled={!canMutateWorkOrderFlag(row.status)}
+ onChange={(c) => guardedHandlers.onPatch(row.id, { flagColor: c })}
/>
|
e.stopPropagation()}>
handlers.onView(row)}
- onEdit={isCanceled ? undefined : () => handlers.onEdit(row)}
+ onEdit={closedForEdit ? undefined : () => handlers.onEdit(row)}
/>
|
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
index 0d170a5d..0664eab6 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
@@ -33,6 +33,7 @@ type WoTableProps = {
weekMonday: string;
onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
+ onRequestComplete?: (row: WorkOrderTableRow) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
@@ -97,6 +98,7 @@ export function WoTable({
weekMonday,
onNavigateToDay,
onOpenSlideOver,
+ onRequestComplete,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
@@ -121,6 +123,7 @@ export function WoTable({
onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver,
+ onRequestComplete,
onOpenCompDoc,
onOpenUplifts,
onOpenMedia,
diff --git a/src/app/(protected)/workorders/_components/list/work-order-complete-dialog.tsx b/src/app/(protected)/workorders/_components/list/work-order-complete-dialog.tsx
new file mode 100644
index 00000000..bc87d99d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-order-complete-dialog.tsx
@@ -0,0 +1,44 @@
+import {
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+} from "@mui/material";
+import { Button } from "@/components/ui/button";
+import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
+
+type WorkOrderCompleteDialogProps = {
+ open: boolean;
+ isPending: boolean;
+ onDialogClose: (() => void) | undefined;
+ onKeepOpen: () => void;
+ onConfirm: () => void;
+};
+
+export function WorkOrderCompleteDialog({
+ open,
+ isPending,
+ onDialogClose,
+ onKeepOpen,
+ onConfirm,
+}: WorkOrderCompleteDialogProps) {
+ 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 (
);
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
-
-
-
-
+ {users.map((user) => (
+
+ ))}
+
+
+ )}
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 (
-
- );
- })}
-
-
+ {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 (
+
+ );
+ })}
+
+ >
+ );
+}
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);
+ });
+});
|