fix(work-orders): lock completed status and legacy detail

Keep Flag, comments, and extra docs writable after completion, and hide the completed status menu.
This commit is contained in:
Arthur Bassi 2026-08-24 09:31:29 -03:00
parent 260fe6c654
commit d9d6334f57
17 changed files with 607 additions and 202 deletions

View file

@ -18,6 +18,10 @@ import {
getClosabilityGaps,
isCompletionStatusLabel,
} from "@/domain/work-orders/utils/wo-closability";
import {
isWorkOrderCoreLocked,
isWorkOrderFullyLocked,
} from "@/domain/work-orders/utils/work-order-lock";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
export default function WorkOrderViewPage() {
@ -42,8 +46,10 @@ export default function WorkOrderViewPage() {
}
const wo = view.wo;
const isLocked = isWorkOrderCoreLocked(wo.status) || isWorkOrderFullyLocked(wo.status);
const handleStatusChange = (status: string) => {
if (isLocked) return;
const isCompletionStatus = isCompletionStatusLabel(status);
if (
isCompletionStatus &&
@ -68,6 +74,7 @@ export default function WorkOrderViewPage() {
onStatusAnchorChange={view.setStatusAnchor}
onStatusChange={handleStatusChange}
onOpenDispatch={() => view.setDispatchOpen(true)}
readOnly={isLocked}
/>
<WorkOrderViewDetails
@ -75,14 +82,22 @@ export default function WorkOrderViewPage() {
users={view.users}
assignAnchor={view.assignAnchor}
onAssignAnchorChange={view.setAssignAnchor}
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })}
onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })}
onAssign={(userId, userName) => {
if (isLocked) return;
view.assignmentMutation.mutate({ userId, userName });
}}
onUnassign={() => {
if (isLocked) return;
view.assignmentMutation.mutate({ userId: "", userName: "" });
}}
readOnly={isLocked}
/>
<WorkOrderViewAccordions wo={view.wo} />
<WorkOrderDispatchesAccordion
wo={view.wo}
vendorViewOnly={isLocked}
onOpenDispatchDetail={view.setDispatchDetailId}
onOpenVendorEditor={view.openVendorEditor}
/>
@ -117,6 +132,7 @@ export default function WorkOrderViewPage() {
loading={view.vendorEditorLoading}
error={view.vendorEditorError}
isSaving={view.vendorMutation.isPending}
viewOnly={isLocked}
onClose={() => {
if (!view.vendorMutation.isPending) view.setVendorEditor(null);
}}

View file

@ -162,10 +162,7 @@ export function useSlideOverEditState({
};
const startEditing = () => {
if (!infoSource) return;
if (isSlideOverInfoLocked(infoSource.status)) {
return;
}
if (!infoSource || isSlideOverInfoLocked(infoSource.status)) return;
setDraft(infoSource);
setBaseline(infoSource);
setEditing(true);

View file

@ -24,6 +24,7 @@ import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
@ -36,6 +37,75 @@ type UseWorkOrderSlideOverArgs = {
saving?: boolean;
};
type CompletionUploadMutate = (
input: { file: File; workOrderVersion: string },
options: { onSuccess: () => Promise<void> | void },
) => void;
function uploadSlideOverCompletionPdf({
file,
row,
infoSource,
onSave,
mutate,
}: {
file: File;
row: WorkOrderTableRow | null;
infoSource: WorkOrderTableRow | null | undefined;
onSave: UseWorkOrderSlideOverArgs["onSave"];
mutate: CompletionUploadMutate;
}) {
const version = infoSource?.rowVersion;
if (!version || !row || !infoSource) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
}
function resolveSlideOverSource(
detail: WorkOrderBoardDetail | undefined,
row: WorkOrderTableRow | null,
) {
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
return { detailForRow, infoSource: detailForRow?.info ?? row };
}
function slideOverReadFlags(status: string | null | undefined) {
return {
isCancelled: isWorkOrderFullyLocked(status),
isCompleted: isWorkOrderCompleted(status),
isDocumentsReadOnly: isWorkOrderDocumentsReadOnly(status),
isCommentsReadOnly: !canMutateWorkOrderComments(status),
isExtrasReadOnly: !canMutateWorkOrderExtraDocs(status),
};
}
function slideOverErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "Failed to load work order details.";
}
export function useWorkOrderSlideOver({
row,
editMode,
@ -60,16 +130,8 @@ export function useWorkOrderSlideOver({
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
const infoSource = detailForRow?.info ?? row;
const isCancelled = isWorkOrderFullyLocked(infoSource?.status);
const isCompleted = isWorkOrderCompleted(infoSource?.status);
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
const isCommentsReadOnly = !canMutateWorkOrderComments(infoSource?.status);
const isExtrasReadOnly = !canMutateWorkOrderExtraDocs(infoSource?.status);
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
const readFlags = slideOverReadFlags(infoSource?.status);
const {
completionMedia,
@ -101,38 +163,17 @@ export function useWorkOrderSlideOver({
});
const uploadCompletionPdf = (file: File) => {
const version = infoSource?.rowVersion;
if (!version || !row || !infoSource) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
uploadSlideOverCompletionPdf({
file,
row,
infoSource,
onSave,
mutate: uploadCompletionMutation.mutate,
});
};
const showDetailSkeleton = isLoading && !detailForRow;
const errorMessage =
error instanceof Error ? error.message : "Failed to load work order details.";
const errorMessage = slideOverErrorMessage(error);
return {
user,
@ -147,11 +188,7 @@ export function useWorkOrderSlideOver({
setTab,
detailForRow,
infoSource,
isCancelled,
isCompleted,
isDocumentsReadOnly,
isCommentsReadOnly,
isExtrasReadOnly,
...readFlags,
completionMedia,
extrasUploads,
mediaPending,

View file

@ -15,14 +15,33 @@ import {
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
const COMPLETED_STATUS_LOCKED_LABEL = "Status is locked because this work order is completed";
type StatusCellProps = {
wo: WorkOrderTableRow;
onChangeStatus: (status: WizardWOStatus) => void;
onOpenSlideOver: () => void;
onPastDueAttempt?: () => void;
onRequestComplete?: () => void;
readOnly?: boolean;
};
function ReadOnlyStatusLabel({ status }: { status: string }) {
return (
<span
className="flex w-full items-center gap-1"
aria-disabled="true"
aria-label={COMPLETED_STATUS_LOCKED_LABEL}
>
{status ? (
<WoTableStatusLabel status={status} />
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
)}
</span>
);
}
function rowClosabilityGaps(wo: WorkOrderTableRow) {
return getClosabilityGaps(tableRowToClosabilityInput(wo));
}
@ -104,10 +123,15 @@ export function StatusCell({
onOpenSlideOver,
onPastDueAttempt,
onRequestComplete,
readOnly = false,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const closabilityGaps = rowClosabilityGaps(wo);
if (readOnly) {
return <ReadOnlyStatusLabel status={wo.status} />;
}
if (wo.isPastDue) {
return (
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />

View file

@ -91,6 +91,7 @@ export function WoTableRowServiceCells({
>
<StatusCell
wo={row}
readOnly={viewOnly}
onChangeStatus={(status) => onPatchRow({ status })}
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}

View file

@ -17,6 +17,7 @@ import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
type WorkOrderDispatchesAccordionProps = {
wo: WorkOrderDetail;
vendorViewOnly?: boolean;
onOpenDispatchDetail: (dispatchId: string | number) => void;
onOpenVendorEditor: (
event: React.MouseEvent,
@ -27,6 +28,7 @@ type WorkOrderDispatchesAccordionProps = {
export function WorkOrderDispatchesAccordion({
wo,
vendorViewOnly = false,
onOpenDispatchDetail,
onOpenVendorEditor,
}: WorkOrderDispatchesAccordionProps) {
@ -95,7 +97,7 @@ export function WorkOrderDispatchesAccordion({
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
{vendorViewOnly ? "View vendor" : "Edit vendor"}
</Button>
</TableCell>
</TableRow>

View file

@ -19,6 +19,7 @@ type WorkOrderVendorEditorDialogProps = {
loading: boolean;
error: string;
isSaving: boolean;
viewOnly?: boolean;
onClose: () => void;
onSave: () => void;
onEditorChange: (
@ -31,13 +32,14 @@ export function WorkOrderVendorEditorDialog({
loading,
error,
isSaving,
viewOnly = false,
onClose,
onSave,
onEditorChange,
}: WorkOrderVendorEditorDialogProps) {
return (
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>Edit assigned vendor</DialogTitle>
<DialogTitle>{viewOnly ? "Assigned vendor" : "Edit assigned vendor"}</DialogTitle>
<DialogContent>
<Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary">
@ -53,6 +55,7 @@ export function WorkOrderVendorEditorDialog({
<TextField
label="Technician name"
value={editor?.contactName ?? ""}
disabled={viewOnly}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, contactName: event.target.value } : current,
@ -63,6 +66,7 @@ export function WorkOrderVendorEditorDialog({
<TextField
label="Phone"
value={editor?.phone ?? ""}
disabled={viewOnly}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
@ -75,6 +79,7 @@ export function WorkOrderVendorEditorDialog({
label="Email"
type="email"
value={editor?.email ?? ""}
disabled={viewOnly}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, email: event.target.value } : current,
@ -86,6 +91,7 @@ export function WorkOrderVendorEditorDialog({
multiline
minRows={3}
value={editor?.notes ?? ""}
disabled={viewOnly}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, notes: event.target.value } : current,
@ -98,16 +104,22 @@ export function WorkOrderVendorEditorDialog({
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
>
{isSaving ? "Saving..." : "Save"}
</Button>
{viewOnly ? (
<Button onClick={onClose}>Close</Button>
) : (
<>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</>
)}
</DialogActions>
</Dialog>
);

View file

@ -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 (
<Paper variant="outlined" className="p-4">
@ -31,40 +33,44 @@ export function WorkOrderViewDetails({
<WorkOrderDetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
<WorkOrderDetailField label="Priority" value={wo.priority || "—"} />
<WorkOrderDetailField label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
Assigned To
</Typography>
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => onAssignAnchorChange(null)}
>
<MenuItem
onClick={() => {
onAssignAnchorChange(null);
onUnassign();
}}
{readOnly ? (
<WorkOrderDetailField label="Assigned To" value={wo.assignedTo || "Unassigned"} />
) : (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
Assigned To
</Typography>
<Button size="small" onClick={(event) => onAssignAnchorChange(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => onAssignAnchorChange(null)}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
onAssignAnchorChange(null);
onAssign(user.id, user.name);
onUnassign();
}}
>
{user.name}
Unassigned
</MenuItem>
))}
</Menu>
</Box>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
onAssignAnchorChange(null);
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</Box>
)}
<WorkOrderDetailField label="Source" value={wo.source || "—"} />
<WorkOrderDetailField label="TT" value={wo.tt || "—"} />
</Box>

View file

@ -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({
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
className="w-fit cursor-pointer"
<WorkOrderViewStatusControl
wo={wo}
readOnly={readOnly}
isClosabilityReady={isClosabilityReady}
statusAnchor={statusAnchor}
onStatusAnchorChange={onStatusAnchorChange}
onStatusChange={onStatusChange}
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => 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 (
<MenuItem
key={status}
selected={status === wo.status}
disabled={isCompletedBlocked}
title={isCompletedBlocked ? blockedTitle : undefined}
onClick={() => {
if (isCompletedBlocked) {
return;
}
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
);
})}
</Menu>
</Stack>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button variant="contained" color="success" onClick={onOpenDispatch}>
Dispatch
</Button>
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
Edit
</Button>
{Boolean(!readOnly) && (
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
Edit
</Button>
)}
<Button variant="outlined" onClick={() => navigate("/workorders")}>
Back to List
</Button>

View file

@ -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 (
<Chip
label={wo.status}
color={chipColor}
className="w-fit"
aria-disabled
aria-label={LOCKED_STATUS_LABEL}
/>
);
}
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
return (
<>
<Chip
label={wo.status}
color={chipColor}
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
className="w-fit cursor-pointer"
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => 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 (
<MenuItem
key={status}
selected={status === wo.status}
disabled={isCompletedBlocked}
title={isCompletedBlocked ? blockedTitle : undefined}
onClick={() => {
if (isCompletedBlocked) {
return;
}
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
);
})}
</Menu>
</>
);
}

View file

@ -117,6 +117,60 @@ function WizardVendorDetail({
);
}
function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardStepVendorTimeProps) {
const duration = calcDuration(draft.apptStart, draft.apptEnd);
return (
<div>
<WizLabel optional>Appt Time</WizLabel>
<div className="flex items-center gap-2">
<WizardFieldSelect
value={draft.apptStart}
placeholder="Start"
icon={
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
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,
})
}
/>
<span className="shrink-0 text-[12px]" style={{ color: "var(--muted-foreground)" }}>
to
</span>
<WizardFieldSelect
value={draft.apptEnd}
placeholder="End"
icon={
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
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)}
/>
</div>
{Boolean(draft.apptStart && draft.apptEnd && duration) && (
<Text
as="p"
variant="caption"
className="mt-2 flex items-center gap-1.5"
sx={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
>
<Clock size={12} /> Duration {duration}
</Text>
)}
</div>
);
}
function buildWizardStatusOptions(closabilityGaps: string[]) {
return ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
@ -207,55 +261,7 @@ export function WizardStepVendorTime({
/>
)}
<div>
<WizLabel optional>Appt Time</WizLabel>
<div className="flex items-center gap-2">
<WizardFieldSelect
value={draft.apptStart}
placeholder="Start"
icon={
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
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,
})
}
/>
<span className="shrink-0 text-[12px]" style={{ color: "var(--muted-foreground)" }}>
to
</span>
<WizardFieldSelect
value={draft.apptEnd}
placeholder="End"
icon={
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
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)}
/>
</div>
{draft.apptStart !== "" &&
draft.apptEnd !== "" &&
Boolean(calcDuration(draft.apptStart, draft.apptEnd)) && (
<Text
as="p"
variant="caption"
className="mt-2 flex items-center gap-1.5"
sx={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
>
<Clock size={12} /> Duration {calcDuration(draft.apptStart, draft.apptEnd)}
</Text>
)}
</div>
<WizardApptTimeFields draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
<div>
<WizLabel>Status</WizLabel>

View file

@ -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) {

View file

@ -31,7 +31,6 @@ const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]);
/**
* Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use
* dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`).
* Those requests may still fail with 422 `ReadOnly` if the backend lock remains total.
*/
export function canApplyWorkOrderTablePatch(
status: string | null | undefined,

View file

@ -24,6 +24,33 @@ describe("Completed comments and extra docs remain writable", () => {
expect(screen.getByRole("button", { name: /post/i })).toBeInTheDocument();
});
it("keeps edit on the author's own comment when status is Completed", () => {
renderWithProviders(
<SlideOverCommentsTab
comments={[
{
id: 11,
createdDate: "2026-07-20T12:00:00Z",
commenttext: "Need a flag on this",
commentType: "General",
userName: "Alex",
authorId: "u1",
commenter: "Alex",
recordType: "WorkOrder",
},
]}
isReadOnly={!canMutateWorkOrderComments("Completed")}
isPending={false}
currentUserId="u1"
onSubmit={vi.fn()}
onUpdateComment={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /edit comment/i })).toBeInTheDocument();
});
it("hides the comment composer when status is Canceled", () => {
renderWithProviders(
<SlideOverCommentsTab

View file

@ -283,6 +283,25 @@ describe("StatusCell pending uplift closability", () => {
expect(onChangeStatus).not.toHaveBeenCalled();
});
it("does not open the status menu when readOnly", () => {
const onChangeStatus = vi.fn();
renderWithProviders(
<StatusCell
wo={makeRow({ status: "Completed" })}
readOnly
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
/>,
{ 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();

View file

@ -104,4 +104,33 @@ describe("WoTableRow completed selective lock", () => {
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
expect(screen.getByTitle("View details")).toBeInTheDocument();
});
it("does not open the status menu on a Completed row", () => {
const row = makeRow();
renderWithProviders(
<table>
<tbody>
<WoTableRow
row={row}
allRows={[row]}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["HVAC"]}
isSelected={false}
isFlashing={false}
dragViz={{ dragId: null, overId: null }}
handlers={handlers}
/>
</tbody>
</table>,
{ 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();
});
});

View file

@ -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(
<MemoryRouter>
<WorkOrderViewHeader
wo={{ ...baseDetail, status }}
workOrderId="1"
isClosabilityReady
statusAnchor={null}
onStatusAnchorChange={vi.fn()}
onStatusChange={onStatusChange}
onOpenDispatch={vi.fn()}
readOnly={readOnly}
/>
</MemoryRouter>,
);
}
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(
<MemoryRouter>
<WorkOrderViewHeader
wo={baseDetail}
workOrderId="1"
isClosabilityReady
statusAnchor={null}
onStatusAnchorChange={onStatusAnchorChange}
onStatusChange={vi.fn()}
onOpenDispatch={vi.fn()}
/>
</MemoryRouter>,
);
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(
<WorkOrderViewDetails
wo={{ ...baseDetail, status: "Completed" }}
users={[{ id: "u2", name: "Blake" }]}
assignAnchor={null}
onAssignAnchorChange={vi.fn()}
onAssign={onAssign}
onUnassign={vi.fn()}
readOnly
/>,
);
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(
<WorkOrderViewDetails
wo={baseDetail}
users={[{ id: "u2", name: "Blake" }]}
assignAnchor={null}
onAssignAnchorChange={vi.fn()}
onAssign={vi.fn()}
onUnassign={vi.fn()}
/>,
);
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(
<WorkOrderVendorEditorDialog
editor={vendorEditor}
loading={false}
error=""
isSaving={false}
viewOnly
onClose={vi.fn()}
onSave={onSave}
onEditorChange={vi.fn()}
/>,
);
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(
<WorkOrderVendorEditorDialog
editor={vendorEditor}
loading={false}
error=""
isSaving={false}
onClose={vi.fn()}
onSave={vi.fn()}
onEditorChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();
expect(screen.getByLabelText("Technician name")).toBeEnabled();
});
});