Merge pull request #131 from Sea-Haven-Industries/feat/sh-115-completed-selective-lock
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(work-orders): lock completed work orders with confirmation (SH-115)
This commit is contained in:
Arthur Bassi 2026-08-24 13:34:07 -03:00 • committed by GitHub
commit cd092b3137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1802 additions and 354 deletions

View file

@ -1,4 +1,5 @@
import { useParams } from "react-router"; import { useParams } from "react-router";
import { useState } from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton"; import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
@ -11,17 +12,23 @@ import { WorkOrderViewAccordions } from "./_components/view/work-order-view-acco
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
import { WorkOrderCompleteDialog } from "./_components/list/work-order-complete-dialog";
import { import {
detailToClosabilityInput, detailToClosabilityInput,
getClosabilityGaps, getClosabilityGaps,
isCompletionStatusLabel, isCompletionStatusLabel,
} from "@/domain/work-orders/utils/wo-closability"; } 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"; import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
export default function WorkOrderViewPage() { export default function WorkOrderViewPage() {
const { id } = useParams(); const { id } = useParams();
const workOrderId = id ?? ""; const workOrderId = id ?? "";
const view = useWorkOrderViewPage(workOrderId); const view = useWorkOrderViewPage(workOrderId);
const [confirmComplete, setConfirmComplete] = useState(false);
if (view.isLoading && !view.wo) { if (view.isLoading && !view.wo) {
return <DetailPageSkeleton variant="view" />; return <DetailPageSkeleton variant="view" />;
@ -39,8 +46,10 @@ export default function WorkOrderViewPage() {
} }
const wo = view.wo; const wo = view.wo;
const isLocked = isWorkOrderCoreLocked(wo.status) || isWorkOrderFullyLocked(wo.status);
const handleStatusChange = (status: string) => { const handleStatusChange = (status: string) => {
if (isLocked) return;
const isCompletionStatus = isCompletionStatusLabel(status); const isCompletionStatus = isCompletionStatusLabel(status);
if ( if (
isCompletionStatus && isCompletionStatus &&
@ -48,6 +57,10 @@ export default function WorkOrderViewPage() {
) { ) {
return; return;
} }
if (isCompletionStatus) {
setConfirmComplete(true);
return;
}
view.statusMutation.mutate(status as WorkOrderStatus); view.statusMutation.mutate(status as WorkOrderStatus);
}; };
@ -61,6 +74,7 @@ export default function WorkOrderViewPage() {
onStatusAnchorChange={view.setStatusAnchor} onStatusAnchorChange={view.setStatusAnchor}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onOpenDispatch={() => view.setDispatchOpen(true)} onOpenDispatch={() => view.setDispatchOpen(true)}
readOnly={isLocked}
/> />
<WorkOrderViewDetails <WorkOrderViewDetails
@ -68,14 +82,22 @@ export default function WorkOrderViewPage() {
users={view.users} users={view.users}
assignAnchor={view.assignAnchor} assignAnchor={view.assignAnchor}
onAssignAnchorChange={view.setAssignAnchor} onAssignAnchorChange={view.setAssignAnchor}
onAssign={(userId, userName) => view.assignmentMutation.mutate({ userId, userName })} onAssign={(userId, userName) => {
onUnassign={() => view.assignmentMutation.mutate({ 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} /> <WorkOrderViewAccordions wo={view.wo} />
<WorkOrderDispatchesAccordion <WorkOrderDispatchesAccordion
wo={view.wo} wo={view.wo}
vendorViewOnly={isLocked}
onOpenDispatchDetail={view.setDispatchDetailId} onOpenDispatchDetail={view.setDispatchDetailId}
onOpenVendorEditor={view.openVendorEditor} onOpenVendorEditor={view.openVendorEditor}
/> />
@ -110,12 +132,24 @@ export default function WorkOrderViewPage() {
loading={view.vendorEditorLoading} loading={view.vendorEditorLoading}
error={view.vendorEditorError} error={view.vendorEditorError}
isSaving={view.vendorMutation.isPending} isSaving={view.vendorMutation.isPending}
viewOnly={isLocked}
onClose={() => { onClose={() => {
if (!view.vendorMutation.isPending) view.setVendorEditor(null); if (!view.vendorMutation.isPending) view.setVendorEditor(null);
}} }}
onSave={view.saveVendorEditor} onSave={view.saveVendorEditor}
onEditorChange={view.setVendorEditor} onEditorChange={view.setVendorEditor}
/> />
<WorkOrderCompleteDialog
open={confirmComplete}
isPending={view.statusMutation.isPending}
onDialogClose={() => setConfirmComplete(false)}
onKeepOpen={() => setConfirmComplete(false)}
onConfirm={() => {
view.statusMutation.mutate("Done");
setConfirmComplete(false);
}}
/>
</Box> </Box>
); );
} }

View file

@ -4,6 +4,7 @@ import { Text } from "@/components/ui/text";
type SlideOverFooterProps = { type SlideOverFooterProps = {
isCancelled: boolean; isCancelled: boolean;
isCompleted?: boolean;
editing: boolean; editing: boolean;
isDirty: boolean; isDirty: boolean;
showUnsaved: boolean; showUnsaved: boolean;
@ -19,6 +20,7 @@ type SlideOverFooterProps = {
export function SlideOverFooter({ export function SlideOverFooter({
isCancelled, isCancelled,
isCompleted = false,
editing, editing,
isDirty, isDirty,
showUnsaved, showUnsaved,
@ -31,6 +33,27 @@ export function SlideOverFooter({
onDiscard, onDiscard,
onKeepEditing, onKeepEditing,
}: SlideOverFooterProps) { }: SlideOverFooterProps) {
if (isCancelled) {
return (
<div
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
style={{
background: "var(--color-bg-muted)",
borderTop: "1px solid var(--color-border)",
}}
>
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</Text>
</div>
);
}
if (isCompleted) {
return null;
}
if (showUnsaved) { if (showUnsaved) {
return ( return (
<div <div
@ -68,23 +91,6 @@ export function SlideOverFooter({
); );
} }
if (isCancelled) {
return (
<div
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
style={{
background: "var(--color-bg-muted)",
borderTop: "1px solid var(--color-border)",
}}
>
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</Text>
</div>
);
}
return ( return (
<div <div
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3" className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"

View file

@ -1,7 +1,14 @@
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row"; import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label"; import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils"; import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
@ -13,13 +20,21 @@ import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field"
type SlideOverInfoTabDisplayViewProps = { type SlideOverInfoTabDisplayViewProps = {
source: WorkOrderTableRow; source: WorkOrderTableRow;
isCancelled: boolean; isCancelled: boolean;
isCompleted?: boolean;
dispatcher?: WorkOrderUser; dispatcher?: WorkOrderUser;
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
}; };
export function SlideOverInfoTabDisplayView({ export function SlideOverInfoTabDisplayView({
source, source,
isCancelled, isCancelled,
isCompleted = false,
dispatcher, dispatcher,
sites,
vendors,
pmTypes,
}: SlideOverInfoTabDisplayViewProps) { }: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime); const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—"; const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
@ -30,9 +45,27 @@ export function SlideOverInfoTabDisplayView({
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span> <span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Site"> <SlideOverInfoRow label="Site">
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}> {isCompleted ? (
{source.site || "—"} <SiteCell
</span> value={source.site}
location={source.location}
locationId={source.locationId}
pocName={source.pocName}
pocPhone={source.pocPhone}
pocNotes={source.pocNotes}
additionalContacts={source.additionalContacts}
sites={sites}
viewOnly
onSave={() => undefined}
/>
) : (
<span
className="font-mono text-[12.5px] font-bold"
style={{ color: "var(--foreground)" }}
>
{source.site || "—"}
</span>
)}
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO"> <SlideOverInfoRow label="Type of WO">
{source.type ? ( {source.type ? (
@ -61,7 +94,17 @@ export function SlideOverInfoTabDisplayView({
<span className="text-[12.5px]">{apptDisplay}</span> <span className="text-[12.5px]">{apptDisplay}</span>
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Service"> <SlideOverInfoRow label="Service">
{source.pm ? ( {isCompleted ? (
<PMTypeCell
value={source.pm}
pmTypes={pmTypes}
serviceNotes={source.serviceNotes}
extraServices={source.extraServices}
woNumber={source.woNumber}
viewOnly
onSave={() => undefined}
/>
) : source.pm ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px]"> <span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} /> <PmTypeIcon pmType={source.pm} size={14} />
{source.pm} {source.pm}
@ -71,7 +114,19 @@ export function SlideOverInfoTabDisplayView({
)} )}
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Vendor"> <SlideOverInfoRow label="Vendor">
<SlideOverInfoTabVendorField source={source} /> {isCompleted ? (
<VendorCell
vendorId={source.vendorId}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
vendors={vendors}
viewOnly
onSave={() => undefined}
/>
) : (
<SlideOverInfoTabVendorField source={source} />
)}
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Status"> <SlideOverInfoRow label="Status">
<SlideOverInfoTabStatusField source={source} /> <SlideOverInfoTabStatusField source={source} />

View file

@ -27,6 +27,7 @@ type SlideOverInfoTabEditViewProps = {
allOrders: WorkOrderTableRow[]; allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void; onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onRequestComplete?: () => void;
}; };
export function SlideOverInfoTabEditView({ export function SlideOverInfoTabEditView({
@ -38,6 +39,7 @@ export function SlideOverInfoTabEditView({
allOrders, allOrders,
onDraftChange, onDraftChange,
onDuplicateFound, onDuplicateFound,
onRequestComplete,
}: SlideOverInfoTabEditViewProps) { }: SlideOverInfoTabEditViewProps) {
return ( return (
<div> <div>
@ -149,6 +151,7 @@ export function SlideOverInfoTabEditView({
wo={draft} wo={draft}
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })} onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
onOpenSlideOver={() => {}} onOpenSlideOver={() => {}}
onRequestComplete={onRequestComplete}
/> />
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc"> <SlideOverInfoRow label="Comp Doc">

View file

@ -12,6 +12,7 @@ type SlideOverInfoTabProps = {
draft: WorkOrderTableRow; draft: WorkOrderTableRow;
editing: boolean; editing: boolean;
isCancelled: boolean; isCancelled: boolean;
isCompleted?: boolean;
users: WorkOrderUser[]; users: WorkOrderUser[];
sites: LocationOption[]; sites: LocationOption[];
vendors: VendorDropdownItem[]; vendors: VendorDropdownItem[];
@ -19,6 +20,7 @@ type SlideOverInfoTabProps = {
allOrders: WorkOrderTableRow[]; allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void; onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onRequestComplete?: () => void;
}; };
export function SlideOverInfoTab({ export function SlideOverInfoTab({
@ -26,6 +28,7 @@ export function SlideOverInfoTab({
draft, draft,
editing, editing,
isCancelled, isCancelled,
isCompleted = false,
users, users,
sites, sites,
vendors, vendors,
@ -33,8 +36,9 @@ export function SlideOverInfoTab({
allOrders, allOrders,
onDraftChange, onDraftChange,
onDuplicateFound, onDuplicateFound,
onRequestComplete,
}: SlideOverInfoTabProps) { }: SlideOverInfoTabProps) {
const isEditing = editing && !isCancelled; const isEditing = editing && !isCancelled && !isCompleted;
const source = isEditing ? draft : row; const source = isEditing ? draft : row;
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId); const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
@ -49,6 +53,7 @@ export function SlideOverInfoTab({
allOrders={allOrders} allOrders={allOrders}
onDraftChange={onDraftChange} onDraftChange={onDraftChange}
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
onRequestComplete={onRequestComplete}
/> />
); );
} }
@ -57,7 +62,11 @@ export function SlideOverInfoTab({
<SlideOverInfoTabDisplayView <SlideOverInfoTabDisplayView
source={source} source={source}
isCancelled={isCancelled} isCancelled={isCancelled}
isCompleted={isCompleted}
dispatcher={dispatcher} dispatcher={dispatcher}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
/> />
); );
} }

View file

@ -7,9 +7,17 @@ import {
buildSlideOverPatch, buildSlideOverPatch,
isSlideOverDraftDirty, isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft"; } 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 { SlideOverSaveOptions } from "./work-order-slide-over";
import type { SlideOverTab } from "./use-work-order-slide-over"; import type { SlideOverTab } from "./use-work-order-slide-over";
function isSlideOverInfoLocked(status: string | null | undefined): boolean {
return isWorkOrderFullyLocked(status) || isWorkOrderCoreLocked(status);
}
type UseSlideOverEditStateArgs = { type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null; row: WorkOrderTableRow | null;
editMode: boolean; editMode: boolean;
@ -49,7 +57,7 @@ export function useSlideOverEditState({
} }
setDraft(row); setDraft(row);
setBaseline(row); setBaseline(row);
setEditing(editMode && (row.status as string) !== "Canceled"); setEditing(editMode && !isSlideOverInfoLocked(row.status));
setTab("info"); setTab("info");
setShowUnsaved(false); setShowUnsaved(false);
setUnsavedIntent(null); setUnsavedIntent(null);
@ -57,6 +65,21 @@ export function useSlideOverEditState({
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]); }, [row?.id, editMode]);
const lockStatus = infoSource?.status ?? row?.status;
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
useEffect(() => {
if (!isInfoLocked) return;
setEditing(false);
setShowUnsaved(false);
setUnsavedIntent(null);
const source = infoSource ?? row;
if (source) {
setDraft(source);
setBaseline(source);
}
}, [isInfoLocked, infoSource, row]);
useEffect(() => { useEffect(() => {
if (!infoSource) return; if (!infoSource) return;
if (!editing) { if (!editing) {
@ -84,6 +107,7 @@ export function useSlideOverEditState({
const commit = () => { const commit = () => {
if (!row || !activeDraft || !dirtyBaseline) return; if (!row || !activeDraft || !dirtyBaseline) return;
if (isSlideOverInfoLocked(infoSource?.status ?? row.status)) return;
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline); const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
if (Object.keys(patch).length === 0) { if (Object.keys(patch).length === 0) {
setEditing(false); setEditing(false);
@ -138,7 +162,7 @@ export function useSlideOverEditState({
}; };
const startEditing = () => { const startEditing = () => {
if (!infoSource) return; if (!infoSource || isSlideOverInfoLocked(infoSource.status)) return;
setDraft(infoSource); setDraft(infoSource);
setBaseline(infoSource); setBaseline(infoSource);
setEditing(true); setEditing(true);

View file

@ -13,11 +13,18 @@ import {
useUploadCompletionDoc, useUploadCompletionDoc,
useUploadWorkOrderMedia, useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations"; } 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 { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state"; import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-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"; import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras"; export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
@ -30,6 +37,75 @@ type UseWorkOrderSlideOverArgs = {
saving?: boolean; 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({ export function useWorkOrderSlideOver({
row, row,
editMode, editMode,
@ -54,13 +130,8 @@ export function useWorkOrderSlideOver({
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info"); const [tab, setTab] = useState<SlideOverTab>("info");
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading. const readFlags = slideOverReadFlags(infoSource?.status);
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
const infoSource = detailForRow?.info ?? row;
const isCancelled = (infoSource?.status as string) === "Canceled";
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
const { const {
completionMedia, completionMedia,
@ -92,38 +163,17 @@ export function useWorkOrderSlideOver({
}); });
const uploadCompletionPdf = (file: File) => { const uploadCompletionPdf = (file: File) => {
const version = infoSource?.rowVersion; uploadSlideOverCompletionPdf({
if (!version || !row || !infoSource) { file,
toast.error("Missing work order version. Refresh and try again."); row,
return; infoSource,
} onSave,
uploadCompletionMutation.mutate( mutate: 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.");
}
},
},
);
}; };
const showDetailSkeleton = isLoading && !detailForRow; const showDetailSkeleton = isLoading && !detailForRow;
const errorMessage = const errorMessage = slideOverErrorMessage(error);
error instanceof Error ? error.message : "Failed to load work order details.";
return { return {
user, user,
@ -138,8 +188,7 @@ export function useWorkOrderSlideOver({
setTab, setTab,
detailForRow, detailForRow,
infoSource, infoSource,
isCancelled, ...readFlags,
isDocumentsReadOnly,
completionMedia, completionMedia,
extrasUploads, extrasUploads,
mediaPending, mediaPending,

View file

@ -26,6 +26,7 @@ type WorkOrderSlideOverTabsProps = {
allOrders: WorkOrderTableRow[]; allOrders: WorkOrderTableRow[];
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenCompletionDoc: () => void; onOpenCompletionDoc: () => void;
onRequestComplete?: () => void;
}; };
export function WorkOrderSlideOverTabs({ export function WorkOrderSlideOverTabs({
@ -39,6 +40,7 @@ export function WorkOrderSlideOverTabs({
allOrders, allOrders,
onDuplicateFound, onDuplicateFound,
onOpenCompletionDoc, onOpenCompletionDoc,
onRequestComplete,
}: WorkOrderSlideOverTabsProps) { }: WorkOrderSlideOverTabsProps) {
const hasError = Boolean(state.error) && !state.detailForRow; const hasError = Boolean(state.error) && !state.detailForRow;
const showContent = Boolean(state.detailForRow || !state.isLoading); const showContent = Boolean(state.detailForRow || !state.isLoading);
@ -52,6 +54,7 @@ export function WorkOrderSlideOverTabs({
draft={activeDraft} draft={activeDraft}
editing={state.editing && !saving} editing={state.editing && !saving}
isCancelled={state.isCancelled} isCancelled={state.isCancelled}
isCompleted={state.isCompleted}
users={users} users={users}
sites={sites} sites={sites}
vendors={vendors} vendors={vendors}
@ -59,6 +62,7 @@ export function WorkOrderSlideOverTabs({
allOrders={allOrders} allOrders={allOrders}
onDraftChange={state.handleDraftChange} onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
onRequestComplete={onRequestComplete}
/> />
)} )}
@ -74,7 +78,7 @@ export function WorkOrderSlideOverTabs({
{state.detailForRow != null && ( {state.detailForRow != null && (
<SlideOverCommentsTab <SlideOverCommentsTab
comments={state.detailForRow.comments} comments={state.detailForRow.comments}
isReadOnly={state.isDocumentsReadOnly} isReadOnly={state.isCommentsReadOnly}
isPending={state.commentMutation.isPending} isPending={state.commentMutation.isPending}
currentUserId={state.user?.id} currentUserId={state.user?.id}
isUpdatePending={state.updateCommentMutation.isPending} isUpdatePending={state.updateCommentMutation.isPending}
@ -142,7 +146,7 @@ export function WorkOrderSlideOverTabs({
> >
<SlideOverExtrasTab <SlideOverExtrasTab
uploads={state.extrasUploads} uploads={state.extrasUploads}
isReadOnly={state.isDocumentsReadOnly} isReadOnly={state.isExtrasReadOnly}
pending={state.mediaPending} pending={state.mediaPending}
onUpload={(files) => { onUpload={(files) => {
void state.uploadExtraFiles(files); void state.uploadExtraFiles(files);

View file

@ -31,6 +31,7 @@ type WorkOrderSlideOverProps = {
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void; onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
onCancelWO: (row: WorkOrderTableRow) => void; onCancelWO: (row: WorkOrderTableRow) => void;
onRequestComplete?: (row: WorkOrderTableRow) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void; onClose: () => void;
saving?: boolean; saving?: boolean;
@ -47,6 +48,7 @@ export function WorkOrderSlideOver({
onSave, onSave,
onOpenCompletionDoc, onOpenCompletionDoc,
onCancelWO, onCancelWO,
onRequestComplete,
onDuplicateFound, onDuplicateFound,
onClose, onClose,
saving, saving,
@ -85,10 +87,12 @@ export function WorkOrderSlideOver({
allOrders={allOrders} allOrders={allOrders}
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)} onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
onRequestComplete={() => onRequestComplete?.(infoSource)}
/> />
<SlideOverFooter <SlideOverFooter
isCancelled={state.isCancelled} isCancelled={state.isCancelled}
isCompleted={state.isCompleted}
editing={state.editing} editing={state.editing}
isDirty={state.isDirty} isDirty={state.isDirty}
showUnsaved={state.showUnsaved} showUnsaved={state.showUnsaved}

View file

@ -16,6 +16,7 @@ type EditableWONumberCellProps = {
allOrders: WorkOrderTableRow[]; allOrders: WorkOrderTableRow[];
onSave: (v: string) => void; onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
locked?: boolean;
}; };
function CounterPill({ label, count }: { label: string; count: number }) { function CounterPill({ label, count }: { label: string; count: number }) {
@ -74,6 +75,7 @@ export function EditableWONumberCell({
allOrders, allOrders,
onSave, onSave,
onDuplicateFound, onDuplicateFound,
locked = false,
}: EditableWONumberCellProps) { }: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
@ -131,6 +133,29 @@ export function EditableWONumberCell({
const invalid = editing && draft.length > 0 && draft.length !== 11; const invalid = editing && draft.length > 0 && draft.length !== 11;
if (locked) {
return (
<div className="flex items-center gap-1.5">
<span
title="WO number cannot be changed after completion"
className="inline-block cursor-default font-mono"
style={{
fontSize: 13,
fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4,
padding: "2px 4px",
}}
>
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
<AddOnPill when={isAddOn} />
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</div>
);
}
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{editing ? ( {editing ? (

View file

@ -22,6 +22,7 @@ type PMTypeCellProps = {
extraServices?: string[]; extraServices?: string[];
woNumber?: string; woNumber?: string;
onSaveDetails?: (details: ServiceNotesDetails) => void; onSaveDetails?: (details: ServiceNotesDetails) => void;
viewOnly?: boolean;
}; };
export function PMTypeCell({ export function PMTypeCell({
@ -33,6 +34,7 @@ export function PMTypeCell({
extraServices = [], extraServices = [],
woNumber, woNumber,
onSaveDetails, onSaveDetails,
viewOnly = false,
}: PMTypeCellProps) { }: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -52,7 +54,7 @@ export function PMTypeCell({
}; };
const modal = const modal =
onSaveDetails != null ? ( onSaveDetails != null || viewOnly ? (
<ServiceNotesDialog <ServiceNotesDialog
open={modalOpen} open={modalOpen}
onOpenChange={setModalOpen} onOpenChange={setModalOpen}
@ -61,10 +63,44 @@ export function PMTypeCell({
serviceNotes={serviceNotes ?? ""} serviceNotes={serviceNotes ?? ""}
woNumber={woNumber} woNumber={woNumber}
pmTypes={pmTypes} pmTypes={pmTypes}
onSave={onSaveDetails} viewOnly={viewOnly}
onSave={onSaveDetails ?? (() => undefined)}
/> />
) : null; ) : null;
if (viewOnly) {
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={value}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
<span
className="min-w-0 truncate"
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<HighlightText text={value} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
</button>
{modal}
</>
);
}
if (hasDetails && onSaveDetails) { if (hasDetails && onSaveDetails) {
return ( return (
<> <>

View file

@ -19,6 +19,7 @@ type ServiceNotesDialogProps = {
serviceNotes: string; serviceNotes: string;
woNumber?: string; woNumber?: string;
pmTypes: string[]; pmTypes: string[];
viewOnly?: boolean;
onSave: (details: ServiceNotesDetails) => void; onSave: (details: ServiceNotesDetails) => void;
}; };
@ -30,6 +31,7 @@ export function ServiceNotesDialog({
serviceNotes, serviceNotes,
woNumber, woNumber,
pmTypes, pmTypes,
viewOnly = false,
onSave, onSave,
}: ServiceNotesDialogProps) { }: ServiceNotesDialogProps) {
const [sel, setSel] = useState<string[]>([]); const [sel, setSel] = useState<string[]>([]);
@ -76,38 +78,52 @@ export function ServiceNotesDialog({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ paddingTop: 8 }} style={{ paddingTop: 8 }}
> >
<WizardServiceNotesFields <fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
services={services} <WizardServiceNotesFields
sel={sel} services={services}
onToggle={toggle} sel={sel}
note={note} onToggle={toggle}
setNote={setNote} note={note}
search={search} setNote={setNote}
setSearch={setSearch} search={search}
/> setSearch={setSearch}
/>
</fieldset>
<div className="mt-2 flex items-center justify-end gap-2"> <div className="mt-2 flex items-center justify-end gap-2">
<Button {viewOnly ? (
type="button" <Button
variant="ghost" type="button"
className="min-h-0 px-2 py-1 text-[12px]" className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Cancel Close
</Button> </Button>
<Button ) : (
type="button" <>
className="min-h-0 px-3 py-1 text-[12px]" <Button
onClick={() => { type="button"
onSave({ variant="ghost"
pm: sel[0] ?? "", className="min-h-0 px-2 py-1 text-[12px]"
extraServices: sel.slice(1), onClick={() => onOpenChange(false)}
serviceNotes: note, >
}); Cancel
onOpenChange(false); </Button>
}} <Button
> type="button"
Save className="min-h-0 px-3 py-1 text-[12px]"
</Button> onClick={() => {
onSave({
pm: sel[0] ?? "",
extraServices: sel.slice(1),
serviceNotes: note,
});
onOpenChange(false);
}}
>
Save
</Button>
</>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -21,6 +21,7 @@ type SiteCellProps = {
q?: string; q?: string;
/** Inline create: editable POC + Site/POC required to confirm the modal. */ /** Inline create: editable POC + Site/POC required to confirm the modal. */
createMode?: boolean; createMode?: boolean;
viewOnly?: boolean;
onSave: (patch: SitePatch) => void; onSave: (patch: SitePatch) => void;
}; };
@ -37,6 +38,7 @@ export function SiteCell({
sites, sites,
q, q,
createMode = false, createMode = false,
viewOnly = false,
onSave, onSave,
}: SiteCellProps) { }: SiteCellProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -99,6 +101,7 @@ export function SiteCell({
additionalContacts={additionalContacts} additionalContacts={additionalContacts}
sites={sites} sites={sites}
createMode={createMode} createMode={createMode}
viewOnly={viewOnly}
onSave={onSave} onSave={onSave}
/> />
</> </>

View file

@ -3,9 +3,20 @@ import { Button } from "@/components/ui/button";
type SiteDialogFooterProps = { type SiteDialogFooterProps = {
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
viewOnly?: boolean;
}; };
export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) { export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) {
if (viewOnly) {
return (
<div className="mt-4 flex items-center justify-end gap-2">
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onClose}>
Close
</Button>
</div>
);
}
return ( return (
<div className="mt-4 flex items-center justify-end gap-2"> <div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}> <Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>

View file

@ -26,6 +26,7 @@ type SiteDialogProps = {
additionalContacts?: WorkOrderAdditionalContact[]; additionalContacts?: WorkOrderAdditionalContact[];
sites: LocationOption[]; sites: LocationOption[];
createMode?: boolean; createMode?: boolean;
viewOnly?: boolean;
onSave: (patch: SitePatch) => void; onSave: (patch: SitePatch) => void;
}; };
@ -40,6 +41,7 @@ export function SiteDialog({
additionalContacts = [], additionalContacts = [],
sites, sites,
createMode = false, createMode = false,
viewOnly = false,
onSave, onSave,
}: SiteDialogProps) { }: SiteDialogProps) {
const s = useSiteDialogState({ const s = useSiteDialogState({
@ -78,7 +80,7 @@ export function SiteDialog({
Select a site and edit its point of contact Select a site and edit its point of contact
</Text> </Text>
<div className="mt-3 space-y-3"> <fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
<div> <div>
<FieldLabel>Site</FieldLabel> <FieldLabel>Site</FieldLabel>
<WizardFieldSelect <WizardFieldSelect
@ -129,9 +131,9 @@ export function SiteDialog({
onPocNotesChange={s.setNotes} onPocNotesChange={s.setNotes}
onAdditionalContactsChange={s.setExtraContacts} onAdditionalContactsChange={s.setExtraContacts}
/> />
</div> </fieldset>
<SiteDialogFooter onClose={s.close} onConfirm={s.attemptSave} /> <SiteDialogFooter viewOnly={viewOnly} onClose={s.close} onConfirm={s.attemptSave} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -15,13 +15,33 @@ import {
import { HoverChevron } from "./hover-chevron"; import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label"; import { WoTableStatusLabel } from "./wo-table-status-label";
const COMPLETED_STATUS_LOCKED_LABEL = "Status is locked because this work order is completed";
type StatusCellProps = { type StatusCellProps = {
wo: WorkOrderTableRow; wo: WorkOrderTableRow;
onChangeStatus: (status: WizardWOStatus) => void; onChangeStatus: (status: WizardWOStatus) => void;
onOpenSlideOver: () => void; onOpenSlideOver: () => void;
onPastDueAttempt?: () => 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) { function rowClosabilityGaps(wo: WorkOrderTableRow) {
return getClosabilityGaps(tableRowToClosabilityInput(wo)); return getClosabilityGaps(tableRowToClosabilityInput(wo));
} }
@ -102,10 +122,16 @@ export function StatusCell({
onChangeStatus, onChangeStatus,
onOpenSlideOver, onOpenSlideOver,
onPastDueAttempt, onPastDueAttempt,
onRequestComplete,
readOnly = false,
}: StatusCellProps) { }: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const closabilityGaps = rowClosabilityGaps(wo); const closabilityGaps = rowClosabilityGaps(wo);
if (readOnly) {
return <ReadOnlyStatusLabel status={wo.status} />;
}
if (wo.isPastDue) { if (wo.isPastDue) {
return ( return (
<PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} /> <PastDueStatusCell onOpenSlideOver={onOpenSlideOver} onPastDueAttempt={onPastDueAttempt} />
@ -154,6 +180,11 @@ export function StatusCell({
if (isCompletedBlocked) { if (isCompletedBlocked) {
return; return;
} }
if (status === "Completed" && wo.status !== "Completed") {
onRequestComplete?.();
setAnchorEl(null);
return;
}
onChangeStatus(status); onChangeStatus(status);
setAnchorEl(null); setAnchorEl(null);
}} }}

View file

@ -11,6 +11,7 @@ type VendorCellProps = {
techPhone?: string; techPhone?: string;
vendors: VendorDropdownItem[]; vendors: VendorDropdownItem[];
q?: string; q?: string;
viewOnly?: boolean;
onSave: (patch: VendorPatch) => void; onSave: (patch: VendorPatch) => void;
}; };
@ -21,6 +22,7 @@ export function VendorCell({
techPhone, techPhone,
vendors, vendors,
q, q,
viewOnly = false,
onSave, onSave,
}: VendorCellProps) { }: VendorCellProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -60,6 +62,7 @@ export function VendorCell({
tech={tech} tech={tech}
techPhone={techPhone} techPhone={techPhone}
vendors={vendors} vendors={vendors}
viewOnly={viewOnly}
onSave={onSave} onSave={onSave}
/> />
</> </>

View file

@ -4,9 +4,25 @@ type VendorDialogFooterProps = {
isPending: boolean; isPending: boolean;
onCancel: () => void; onCancel: () => void;
onSave: () => void; onSave: () => void;
viewOnly?: boolean;
}; };
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) { export function VendorDialogFooter({
isPending,
onCancel,
onSave,
viewOnly = false,
}: VendorDialogFooterProps) {
if (viewOnly) {
return (
<div className="mt-4 flex items-center justify-end gap-2">
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onCancel}>
Close
</Button>
</div>
);
}
return ( return (
<div className="mt-4 flex items-center justify-end gap-2"> <div className="mt-4 flex items-center justify-end gap-2">
<Button <Button

View file

@ -29,6 +29,7 @@ type VendorDialogProps = {
tech: string; tech: string;
techPhone?: string; techPhone?: string;
vendors: VendorDropdownItem[]; vendors: VendorDropdownItem[];
viewOnly?: boolean;
onSave: (patch: VendorPatch) => void; onSave: (patch: VendorPatch) => void;
}; };
@ -40,6 +41,7 @@ export function VendorDialog({
tech, tech,
techPhone = "", techPhone = "",
vendors, vendors,
viewOnly = false,
onSave, onSave,
}: VendorDialogProps) { }: VendorDialogProps) {
const createVendor = useCreateVendor(); const createVendor = useCreateVendor();
@ -145,24 +147,27 @@ export function VendorDialog({
Search and select the technician Search and select the technician
</Text> </Text>
<VendorDialogBody <fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
selectedKey={selectedKey} <VendorDialogBody
technicianOptions={technicianOptions} selectedKey={selectedKey}
anchorCompanyName={anchorCompanyName} technicianOptions={technicianOptions}
companyVendorForAdd={companyVendor ?? undefined} anchorCompanyName={anchorCompanyName}
tech={selected?.name ?? t} companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
phone={phone} tech={selected?.name ?? t}
notes={notes} phone={phone}
resolvedCompany={resolvedCompany} notes={notes}
selectedVendor={selectedVendor} resolvedCompany={resolvedCompany}
saveError={saveError} selectedVendor={selectedVendor}
onPick={handlePick} saveError={saveError}
onPhoneChange={setPhone} onPick={handlePick}
onNotesChange={setNotes} onPhoneChange={setPhone}
onTechnicianCreated={handleTechnicianCreated} onNotesChange={setNotes}
/> onTechnicianCreated={handleTechnicianCreated}
/>
</fieldset>
<VendorDialogFooter <VendorDialogFooter
viewOnly={viewOnly}
isPending={createVendor.isPending} isPending={createVendor.isPending}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
onSave={handleSave} onSave={handleSave}

View file

@ -15,6 +15,7 @@ type UseWoTableRowHandlersOptions = {
onNavigateToDay: (dateIso: string) => void; onNavigateToDay: (dateIso: string) => void;
onOpenAdvancedFilters?: () => void; onOpenAdvancedFilters?: () => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onRequestComplete?: (row: WorkOrderTableRow) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void;
@ -29,6 +30,7 @@ export function useWoTableRowHandlers({
onNavigateToDay, onNavigateToDay,
onOpenAdvancedFilters, onOpenAdvancedFilters,
onOpenSlideOver, onOpenSlideOver,
onRequestComplete,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
onOpenMedia, onOpenMedia,
@ -110,6 +112,7 @@ export function useWoTableRowHandlers({
onDuplicateFound?.(existing); onDuplicateFound?.(existing);
}, },
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false), onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onRequestComplete,
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
onReorder: reorderRows, onReorder: reorderRows,
onDragStart: (id: string | number) => { onDragStart: (id: string | number) => {
@ -143,6 +146,7 @@ export function useWoTableRowHandlers({
onNavigateToDay, onNavigateToDay,
onOpenAdvancedFilters, onOpenAdvancedFilters,
onOpenSlideOver, onOpenSlideOver,
onRequestComplete,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
onOpenMedia, onOpenMedia,

View file

@ -36,6 +36,7 @@ type WoTableInlineRowCellsProps = {
woShowError: boolean; woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void; onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onRequestComplete?: () => void;
}; };
function InlineTypeSeverityCell({ function InlineTypeSeverityCell({
@ -130,6 +131,7 @@ export function WoTableInlineRowCells({
woShowError, woShowError,
onPatch, onPatch,
onDuplicateFound, onDuplicateFound,
onRequestComplete,
}: WoTableInlineRowCellsProps) { }: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft); const draftRow = toInlineDraftRow(draft);
@ -213,6 +215,7 @@ export function WoTableInlineRowCells({
wo={draftRow as never} wo={draftRow as never}
onChangeStatus={(status) => onPatch({ status })} onChangeStatus={(status) => onPatch({ status })}
onOpenSlideOver={() => {}} onOpenSlideOver={() => {}}
onRequestComplete={onRequestComplete}
/> />
</td> </td>

View file

@ -1,5 +1,6 @@
import { useRef, useState, type RefObject } from "react"; import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react"; import { Flag } from "lucide-react";
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
import type { import type {
LocationOption, LocationOption,
@ -73,6 +74,7 @@ export function WoTableInlineRow({
const missing = getInlineCreateMissing(draft); const missing = getInlineCreateMissing(draft);
const canSave = !saving && missing.length === 0; const canSave = !saving && missing.length === 0;
const [showErrors, setShowErrors] = useState(false); const [showErrors, setShowErrors] = useState(false);
const [confirmComplete, setConfirmComplete] = useState(false);
const siteCellRef = useRef<HTMLTableCellElement>(null); const siteCellRef = useRef<HTMLTableCellElement>(null);
const woCellRef = useRef<HTMLTableCellElement>(null); const woCellRef = useRef<HTMLTableCellElement>(null);
@ -136,6 +138,7 @@ export function WoTableInlineRow({
woShowError={woShowError} woShowError={woShowError}
onPatch={patch} onPatch={patch}
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
onRequestComplete={() => setConfirmComplete(true)}
/> />
<td style={{ padding: "0 4px" }}> <td style={{ padding: "0 4px" }}>
@ -146,6 +149,16 @@ export function WoTableInlineRow({
onSave={attemptSave} onSave={attemptSave}
onCancel={onCancel} onCancel={onCancel}
/> />
<WorkOrderCompleteDialog
open={confirmComplete}
isPending={false}
onDialogClose={() => setConfirmComplete(false)}
onKeepOpen={() => setConfirmComplete(false)}
onConfirm={() => {
patch({ status: "Completed" });
setConfirmComplete(false);
}}
/>
</td> </td>
</tr> </tr>
); );

View file

@ -19,6 +19,8 @@ type WoTableRowCellsProps = {
forceScheduleHighlight?: boolean; forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean; forceDueDateHighlight?: boolean;
mutationCellStyle?: CSSProperties; mutationCellStyle?: CSSProperties;
viewOnly?: boolean;
canceledLockStyle?: CSSProperties;
handlers: WoTableRowHandlers; handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void; onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
}; };
@ -33,6 +35,8 @@ export function WoTableRowCells({
forceScheduleHighlight, forceScheduleHighlight,
forceDueDateHighlight, forceDueDateHighlight,
mutationCellStyle, mutationCellStyle,
viewOnly = false,
canceledLockStyle,
handlers, handlers,
onPatchRow, onPatchRow,
}: WoTableRowCellsProps) { }: WoTableRowCellsProps) {
@ -40,7 +44,7 @@ export function WoTableRowCells({
<> <>
<td <td
className="group/cell" className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...canceledLockStyle }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<SiteCell <SiteCell
@ -55,6 +59,7 @@ export function WoTableRowCells({
additionalContacts={row.additionalContacts} additionalContacts={row.additionalContacts}
sites={sites} sites={sites}
q={search} q={search}
viewOnly={viewOnly}
onSave={(p) => onSave={(p) =>
onPatchRow({ onPatchRow({
site: p.site, site: p.site,
@ -79,6 +84,7 @@ export function WoTableRowCells({
q={search} q={search}
currentId={row.id} currentId={row.id}
allOrders={allRows} allOrders={allRows}
locked={viewOnly}
onSave={(v) => onPatchRow({ woNumber: v })} onSave={(v) => onPatchRow({ woNumber: v })}
onDuplicateFound={handlers.onDuplicateFound} onDuplicateFound={handlers.onDuplicateFound}
/> />

View file

@ -19,6 +19,8 @@ type WoTableRowServiceCellsProps = {
pmTypes: string[]; pmTypes: string[];
search?: string; search?: string;
mutationCellStyle?: CSSProperties; mutationCellStyle?: CSSProperties;
viewOnly?: boolean;
canceledLockStyle?: CSSProperties;
handlers: WoTableRowHandlers; handlers: WoTableRowHandlers;
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void; onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
}; };
@ -29,6 +31,8 @@ export function WoTableRowServiceCells({
pmTypes, pmTypes,
search, search,
mutationCellStyle, mutationCellStyle,
viewOnly = false,
canceledLockStyle,
handlers, handlers,
onPatchRow, onPatchRow,
}: WoTableRowServiceCellsProps) { }: WoTableRowServiceCellsProps) {
@ -36,7 +40,7 @@ export function WoTableRowServiceCells({
<> <>
<td <td
className="group/cell" className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...canceledLockStyle }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<PMTypeCell <PMTypeCell
@ -46,6 +50,7 @@ export function WoTableRowServiceCells({
serviceNotes={row.serviceNotes} serviceNotes={row.serviceNotes}
extraServices={row.extraServices} extraServices={row.extraServices}
woNumber={row.woNumber} woNumber={row.woNumber}
viewOnly={viewOnly}
onSave={(pm) => onPatchRow({ pm })} onSave={(pm) => onPatchRow({ pm })}
onSaveDetails={(d) => onSaveDetails={(d) =>
onPatchRow({ onPatchRow({
@ -58,7 +63,7 @@ export function WoTableRowServiceCells({
</td> </td>
<td <td
className="group/cell" className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }} style={{ padding: "0 14px", ...canceledLockStyle }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<VendorCell <VendorCell
@ -68,6 +73,7 @@ export function WoTableRowServiceCells({
techPhone={row.techPhone} techPhone={row.techPhone}
vendors={vendors} vendors={vendors}
q={search} q={search}
viewOnly={viewOnly}
onSave={(p) => onPatchRow(toVendorTablePatch(p))} onSave={(p) => onPatchRow(toVendorTablePatch(p))}
/> />
</td> </td>
@ -85,9 +91,11 @@ export function WoTableRowServiceCells({
> >
<StatusCell <StatusCell
wo={row} wo={row}
readOnly={viewOnly}
onChangeStatus={(status) => onPatchRow({ status })} onChangeStatus={(status) => onPatchRow({ status })}
onOpenSlideOver={() => handlers.onOpenSlideOver(row)} onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)} onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
onRequestComplete={() => handlers.onRequestComplete?.(row)}
/> />
</td> </td>
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}> <td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>

View file

@ -6,6 +6,12 @@ import type {
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; 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 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 { FlagCell } from "./cells/flag-cell";
import { WoTableRowActions } from "./wo-table-row-actions"; import { WoTableRowActions } from "./wo-table-row-actions";
import { WoTableRowCells } from "./wo-table-row-cells"; import { WoTableRowCells } from "./wo-table-row-cells";
@ -28,6 +34,7 @@ export type WoTableRowHandlers = {
onScheduleWeek: (id: string | number, week: string) => void; onScheduleWeek: (id: string | number, week: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenSlideOver: (row: WorkOrderTableRow) => void; onOpenSlideOver: (row: WorkOrderTableRow) => void;
onRequestComplete?: (row: WorkOrderTableRow) => void;
onPastDueAttempt?: (id: string | number) => void; onPastDueAttempt?: (id: string | number) => void;
onReorder: (dragId: string | number, overId: string | number) => void; onReorder: (dragId: string | number, overId: string | number) => void;
onDragStart: (id: string | number) => void; onDragStart: (id: string | number) => void;
@ -71,15 +78,33 @@ export function WoTableRow({
}: WoTableRowProps) { }: WoTableRowProps) {
const isDragging = dragViz.dragId === row.id; const isDragging = dragViz.dragId === row.id;
const isDropTarget = dragViz.overId === row.id && 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<WorkOrderTableRow>) => { const patchRow = (patch: Partial<WorkOrderTableRow>) => {
if (isCanceled) return; if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(row.id, patch); handlers.onPatch(row.id, patch);
}; };
/** Block mutation controls on canceled rows while keeping row/View clickable. */ const coreLockStyle = closedForEdit ? MUTATION_CELL_STYLE : undefined;
const mutationCellStyle = isCanceled ? MUTATION_CELL_STYLE : undefined; const canceledLockStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
return ( return (
<tr <tr
@ -122,11 +147,7 @@ export function WoTableRow({
}} }}
> >
<td <td
style={{ style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
padding: "0 0 0 6px",
boxShadow: resolveFirstCellShadow(row),
...mutationCellStyle,
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span <span
@ -139,14 +160,11 @@ export function WoTableRow({
<GripVertical size={14} /> <GripVertical size={14} />
</span> </span>
</td> </td>
<td <td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
style={{ padding: "0 2px 0 8px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<FlagCell <FlagCell
color={row.flagColor} color={row.flagColor}
disabled={isCanceled} disabled={!canMutateWorkOrderFlag(row.status)}
onChange={(c) => handlers.onPatch(row.id, { flagColor: c })} onChange={(c) => guardedHandlers.onPatch(row.id, { flagColor: c })}
/> />
</td> </td>
<WoTableRowCells <WoTableRowCells
@ -158,8 +176,10 @@ export function WoTableRow({
search={search} search={search}
forceScheduleHighlight={forceScheduleHighlight} forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight} forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={mutationCellStyle} mutationCellStyle={coreLockStyle}
handlers={handlers} viewOnly={isCoreLocked}
canceledLockStyle={canceledLockStyle}
handlers={guardedHandlers}
onPatchRow={patchRow} onPatchRow={patchRow}
/> />
<WoTableRowServiceCells <WoTableRowServiceCells
@ -167,14 +187,16 @@ export function WoTableRow({
vendors={vendors} vendors={vendors}
pmTypes={pmTypes} pmTypes={pmTypes}
search={search} search={search}
mutationCellStyle={mutationCellStyle} mutationCellStyle={coreLockStyle}
handlers={handlers} viewOnly={isCoreLocked}
canceledLockStyle={canceledLockStyle}
handlers={guardedHandlers}
onPatchRow={patchRow} onPatchRow={patchRow}
/> />
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}> <td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
<WoTableRowActions <WoTableRowActions
onView={() => handlers.onView(row)} onView={() => handlers.onView(row)}
onEdit={isCanceled ? undefined : () => handlers.onEdit(row)} onEdit={closedForEdit ? undefined : () => handlers.onEdit(row)}
/> />
</td> </td>
</tr> </tr>

View file

@ -33,6 +33,7 @@ type WoTableProps = {
weekMonday: string; weekMonday: string;
onNavigateToDay: (dateIso: string) => void; onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onRequestComplete?: (row: WorkOrderTableRow) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */ /** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenUplifts?: (row: WorkOrderTableRow) => void;
@ -97,6 +98,7 @@ export function WoTable({
weekMonday, weekMonday,
onNavigateToDay, onNavigateToDay,
onOpenSlideOver, onOpenSlideOver,
onRequestComplete,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
onOpenMedia, onOpenMedia,
@ -121,6 +123,7 @@ export function WoTable({
onNavigateToDay, onNavigateToDay,
onOpenAdvancedFilters, onOpenAdvancedFilters,
onOpenSlideOver, onOpenSlideOver,
onRequestComplete,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
onOpenMedia, onOpenMedia,

View file

@ -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 (
<Dialog open={open} onClose={onDialogClose} slotProps={dialogBlurBackdropSlotProps}>
<DialogTitle>Mark as Complete?</DialogTitle>
<DialogContent>
<DialogContentText>
Once marked as complete, this work order cannot be edited anymore.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button variant="outline" onClick={onKeepOpen} disabled={isPending}>
Cancel
</Button>
<Button onClick={onConfirm} loading={isPending} loadingText="Saving…">
Mark as Complete
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -3,6 +3,7 @@ import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/det
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; 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 { 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 { 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 { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard"; 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"; 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)} onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onCancelWO={page.setConfirmCancel} onCancelWO={page.setConfirmCancel}
onRequestComplete={page.setConfirmComplete}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending} saving={page.isPatchPending}
onClose={page.handleCloseSlideOver} onClose={page.handleCloseSlideOver}
@ -71,6 +73,14 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
onConfirm={page.handleConfirmCancel} onConfirm={page.handleConfirmCancel}
/> />
<WorkOrderCompleteDialog
open={Boolean(page.confirmComplete)}
isPending={page.isPatchPending}
onDialogClose={page.completeDialogClose}
onKeepOpen={() => page.setConfirmComplete(null)}
onConfirm={page.handleConfirmComplete}
/>
{page.activeMediaRow != null && ( {page.activeMediaRow != null && (
<MediaUploadModal <MediaUploadModal
row={page.activeMediaRow} row={page.activeMediaRow}

View file

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

View file

@ -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 <Button onClick={onClose}>Close</Button>;
}
return (
<>
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</>
);
}

View file

@ -1,24 +1,24 @@
import { import {
Alert, Alert,
Box, Box,
Button,
CircularProgress, CircularProgress,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Stack, Stack,
TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import type { VendorEditorState } from "./work-order-vendor-editor-types"; 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 = { type WorkOrderVendorEditorDialogProps = {
editor: VendorEditorState | null; editor: VendorEditorState | null;
loading: boolean; loading: boolean;
error: string; error: string;
isSaving: boolean; isSaving: boolean;
viewOnly?: boolean;
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
onEditorChange: ( onEditorChange: (
@ -31,13 +31,14 @@ export function WorkOrderVendorEditorDialog({
loading, loading,
error, error,
isSaving, isSaving,
viewOnly = false,
onClose, onClose,
onSave, onSave,
onEditorChange, onEditorChange,
}: WorkOrderVendorEditorDialogProps) { }: WorkOrderVendorEditorDialogProps) {
return ( return (
<Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm"> <Dialog open={editor !== null} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>Edit assigned vendor</DialogTitle> <DialogTitle>{viewOnly ? "Assigned vendor" : "Edit assigned vendor"}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} className="pt-2"> <Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@ -49,65 +50,23 @@ export function WorkOrderVendorEditorDialog({
<CircularProgress size={28} /> <CircularProgress size={28} />
</Box> </Box>
) : ( ) : (
<> <WorkOrderVendorEditorFields
<TextField editor={editor}
label="Technician name" disabled={viewOnly}
value={editor?.contactName ?? ""} onEditorChange={onEditorChange}
onChange={(event) => />
onEditorChange((current) =>
current ? { ...current, contactName: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
label="Phone"
value={editor?.phone ?? ""}
onChange={(event) =>
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"
/>
<TextField
label="Email"
type="email"
value={editor?.email ?? ""}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, email: event.target.value } : current,
)
}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={editor?.notes ?? ""}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, notes: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
)} )}
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} disabled={isSaving}> <WorkOrderVendorEditorActions
Cancel viewOnly={viewOnly}
</Button> editor={editor}
<Button loading={loading}
variant="contained" isSaving={isSaving}
onClick={onSave} onClose={onClose}
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)} onSave={onSave}
> />
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View file

@ -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 (
<>
<TextField
label="Technician name"
value={editor?.contactName ?? ""}
disabled={disabled}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, contactName: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
label="Phone"
value={editor?.phone ?? ""}
disabled={disabled}
onChange={(event) =>
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"
/>
<TextField
label="Email"
type="email"
value={editor?.email ?? ""}
disabled={disabled}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, email: event.target.value } : current,
)
}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={editor?.notes ?? ""}
disabled={disabled}
onChange={(event) =>
onEditorChange((current) =>
current ? { ...current, notes: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
);
}

View file

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

View file

@ -1,26 +1,7 @@
import { Link as RouterLink, useNavigate } from "react-router"; import { Link as RouterLink, useNavigate } from "react-router";
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material"; import { Breadcrumbs, Button, Link, Stack, Typography } 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 type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { import { WorkOrderViewStatusControl } from "./work-order-view-status-control";
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";
type WorkOrderViewHeaderProps = { type WorkOrderViewHeaderProps = {
wo: WorkOrderDetail; wo: WorkOrderDetail;
@ -31,6 +12,7 @@ type WorkOrderViewHeaderProps = {
onStatusAnchorChange: (anchor: HTMLElement | null) => void; onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: string) => void; onStatusChange: (status: string) => void;
onOpenDispatch: () => void; onOpenDispatch: () => void;
readOnly?: boolean;
}; };
export function WorkOrderViewHeader({ export function WorkOrderViewHeader({
@ -41,9 +23,9 @@ export function WorkOrderViewHeader({
onStatusAnchorChange, onStatusAnchorChange,
onStatusChange, onStatusChange,
onOpenDispatch, onOpenDispatch,
readOnly = false,
}: WorkOrderViewHeaderProps) { }: WorkOrderViewHeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
return ( return (
<> <>
@ -62,54 +44,25 @@ export function WorkOrderViewHeader({
<Typography variant="h5" component="h1"> <Typography variant="h5" component="h1">
{wo.title} {wo.title}
</Typography> </Typography>
<Chip <WorkOrderViewStatusControl
label={wo.status} wo={wo}
color={STATUS_COLORS[wo.status] ?? "default"} readOnly={readOnly}
onClick={(event) => onStatusAnchorChange(event.currentTarget)} isClosabilityReady={isClosabilityReady}
className="w-fit cursor-pointer" 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>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}> <Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button variant="contained" color="success" onClick={onOpenDispatch}> <Button variant="contained" color="success" onClick={onOpenDispatch}>
Dispatch Dispatch
</Button> </Button>
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}> {Boolean(!readOnly) && (
Edit <Button variant="contained" onClick={() => navigate(`/workorders/edit/${workOrderId}`)}>
</Button> Edit
</Button>
)}
<Button variant="outlined" onClick={() => navigate("/workorders")}> <Button variant="outlined" onClick={() => navigate("/workorders")}>
Back to List Back to List
</Button> </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

@ -1,3 +1,4 @@
import { useState } from "react";
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
@ -19,6 +20,7 @@ import {
wizardDraftToClosabilityInput, wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability"; } from "@/domain/work-orders/utils/wo-closability";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
import { import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
DLG_INPUT_STYLE, DLG_INPUT_STYLE,
@ -115,6 +117,62 @@ function WizardVendorDetail({
); );
} }
type WizardApptTimeFieldsProps = Pick<WizardStepVendorTimeProps, "draft" | "onUpdate" | "onPatch">;
function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFieldsProps) {
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[]) { function buildWizardStatusOptions(closabilityGaps: string[]) {
return ALL_WIZARD_STATUSES.map((status) => { return ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0; const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
@ -134,6 +192,7 @@ export function WizardStepVendorTime({
onUpdate, onUpdate,
onPatch, onPatch,
}: WizardStepVendorTimeProps) { }: WizardStepVendorTimeProps) {
const [confirmComplete, setConfirmComplete] = useState(false);
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId); const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
const statusValue = (draft.status || "Incomplete") as WizardWOStatus; const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
@ -204,55 +263,7 @@ export function WizardStepVendorTime({
/> />
)} )}
<div> <WizardApptTimeFields draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
<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>
<div> <div>
<WizLabel>Status</WizLabel> <WizLabel>Status</WizLabel>
@ -264,10 +275,24 @@ export function WizardStepVendorTime({
options={buildWizardStatusOptions(closabilityGaps)} options={buildWizardStatusOptions(closabilityGaps)}
onPick={(status) => { onPick={(status) => {
if (status === "Completed" && closabilityGaps.length > 0) return; if (status === "Completed" && closabilityGaps.length > 0) return;
if (status === "Completed" && draft.status !== "Completed") {
setConfirmComplete(true);
return;
}
onUpdate("status", status as WizardWOStatus); onUpdate("status", status as WizardWOStatus);
}} }}
/> />
</div> </div>
<WorkOrderCompleteDialog
open={confirmComplete}
isPending={false}
onDialogClose={() => setConfirmComplete(false)}
onKeepOpen={() => setConfirmComplete(false)}
onConfirm={() => {
onUpdate("status", "Completed");
setConfirmComplete(false);
}}
/>
</div> </div>
); );
} }

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 { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions"; import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board"; 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) { export function useWorkOrderViewPage(workOrderId: string) {
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
@ -101,6 +105,7 @@ export function useWorkOrderViewPage(workOrderId: string) {
}; };
const saveVendorEditor = () => { const saveVendorEditor = () => {
if (isWorkOrderCoreLocked(wo?.status) || isWorkOrderFullyLocked(wo?.status)) return;
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return; if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId); const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {

View file

@ -17,6 +17,7 @@ export function useWorkOrdersListPage() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null); const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false); const [slideOverEdit, setSlideOverEdit] = useState(false);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null); const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null); const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null); const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null); const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
@ -86,7 +87,14 @@ export function useWorkOrdersListPage() {
handleCloseSlideOver(); handleCloseSlideOver();
}; };
const handleConfirmComplete = () => {
if (!confirmComplete) return;
patchField(confirmComplete.id, { status: "Completed" });
setConfirmComplete(null);
};
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null)); const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
const completeDialogClose = guardDialogClose(isPatchPending, () => setConfirmComplete(null));
const saveCompletionDoc = async ( const saveCompletionDoc = async (
row: WorkOrderTableRow, row: WorkOrderTableRow,
@ -109,6 +117,8 @@ export function useWorkOrdersListPage() {
slideOverEdit, slideOverEdit,
confirmCancel, confirmCancel,
setConfirmCancel, setConfirmCancel,
confirmComplete,
setConfirmComplete,
docRow, docRow,
setDocRow, setDocRow,
mediaRow, mediaRow,
@ -133,7 +143,9 @@ export function useWorkOrdersListPage() {
handleOpenSlideOver, handleOpenSlideOver,
handleCloseSlideOver, handleCloseSlideOver,
handleConfirmCancel, handleConfirmCancel,
handleConfirmComplete,
cancelDialogClose, cancelDialogClose,
completeDialogClose,
saveCompletionDoc, saveCompletionDoc,
}; };
} }

View file

@ -98,6 +98,7 @@ export default function WorkOrdersListPage() {
weekMonday={filters.weekMonday} weekMonday={filters.weekMonday}
onNavigateToDay={filters.navigateToDay} onNavigateToDay={filters.navigateToDay}
onOpenSlideOver={page.handleOpenSlideOver} onOpenSlideOver={page.handleOpenSlideOver}
onRequestComplete={page.setConfirmComplete}
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)} onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow} onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow} onOpenUplifts={page.setUpliftsRow}

View file

@ -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<string, unknown>,
): 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);
}

View file

@ -29,9 +29,11 @@ export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): str
return `${Math.round(maxBytes / (1024 * 1024))} MB`; 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 { export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
return status === "Canceled" || status === "Completed"; return isWorkOrderCompletionSurfaceReadOnly(status);
} }
export function canDeleteWorkOrderMedia(media: { export function canDeleteWorkOrderMedia(media: {

View file

@ -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(
<SiteDialog
open
onOpenChange={vi.fn()}
value="BK5"
locationId="loc-1"
sites={[{ id: "loc-1", name: "BK5" }]}
viewOnly
onSave={onSave}
/>,
{ 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(
<ServiceNotesDialog
open
onOpenChange={vi.fn()}
pm="HVAC"
extraServices={[]}
serviceNotes="Check filters"
pmTypes={["HVAC"]}
viewOnly
onSave={onSave}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
});
});

View file

@ -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(
<SlideOverCommentsTab
comments={[]}
isReadOnly={!canMutateWorkOrderComments("Completed")}
isPending={false}
onSubmit={vi.fn()}
/>,
{ 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(
<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
comments={[]}
isReadOnly={!canMutateWorkOrderComments("Canceled")}
isPending={false}
onSubmit={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.queryByPlaceholderText("Add comment…")).not.toBeInTheDocument();
});
it("shows extra-docs upload when status is Completed", () => {
renderWithProviders(
<SlideOverExtrasTab
uploads={[]}
isReadOnly={!canMutateWorkOrderExtraDocs("Completed")}
pending={false}
onUpload={vi.fn()}
onRemove={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByText(/upload documents/i)).toBeInTheDocument();
});
it("hides extra-docs upload when status is Canceled", () => {
renderWithProviders(
<SlideOverExtrasTab
uploads={[]}
isReadOnly={!canMutateWorkOrderExtraDocs("Canceled")}
pending={false}
onUpload={vi.fn()}
onRemove={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.queryByText(/upload documents/i)).not.toBeInTheDocument();
});
});

View file

@ -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(
<SlideOverFooter
isCancelled={false}
isCompleted
editing={false}
isDirty={false}
showUnsaved={false}
{...footerHandlers}
/>,
{ 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(
<SlideOverFooter
isCancelled={false}
isCompleted
editing
isDirty
showUnsaved={false}
{...footerHandlers}
/>,
{ 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(
<SlideOverFooter
isCancelled={false}
isCompleted
editing
isDirty
showUnsaved
{...footerHandlers}
/>,
{ withAuth: false },
);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByText(/unsaved changes/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^save$/i })).not.toBeInTheDocument();
});
});

View file

@ -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(); const onChangeStatus = vi.fn();
renderWithProviders( 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: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/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(
<StatusCell
wo={makeRow()}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
onRequestComplete={onRequestComplete}
/>,
{ 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", () => { 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", () => { it("allows Completed when vendorId is set even if company label is empty", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
const onRequestComplete = vi.fn();
renderWithProviders( renderWithProviders(
<StatusCell <StatusCell
wo={makeRow({ company: "", vendorId: "7", tech: "" })} wo={makeRow({ company: "", vendorId: "7", tech: "" })}
onChangeStatus={onChangeStatus} onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()} onOpenSlideOver={vi.fn()}
onRequestComplete={onRequestComplete}
/>, />,
{ withAuth: false }, { 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: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/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", () => { it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
const onRequestComplete = vi.fn();
renderWithProviders( renderWithProviders(
<StatusCell <StatusCell
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })} wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
onChangeStatus={onChangeStatus} onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()} onOpenSlideOver={vi.fn()}
onRequestComplete={onRequestComplete}
/>, />,
{ withAuth: false }, { 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: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/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)( 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", () => { it("allows Completed after a vendor patch clears leftover refused status", () => {
const onChangeStatus = vi.fn(); const onChangeStatus = vi.fn();
const onRequestComplete = vi.fn();
renderWithProviders( renderWithProviders(
<StatusCell <StatusCell
@ -243,6 +271,7 @@ describe("StatusCell pending uplift closability", () => {
})} })}
onChangeStatus={onChangeStatus} onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()} onOpenSlideOver={vi.fn()}
onRequestComplete={onRequestComplete}
/>, />,
{ withAuth: false }, { 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: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/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(); const onChangeStatus = vi.fn();
renderWithProviders( renderWithProviders(
<StatusCell <StatusCell
wo={makeRow({ tech: "" })} wo={makeRow({ status: "Completed" })}
readOnly
onChangeStatus={onChangeStatus} onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()} onOpenSlideOver={vi.fn()}
/>, />,
{ withAuth: false }, { 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(
<StatusCell
wo={makeRow({ tech: "" })}
onChangeStatus={onChangeStatus}
onOpenSlideOver={vi.fn()}
onRequestComplete={onRequestComplete}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /in progress/i })); fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
fireEvent.click(screen.getByRole("button", { name: /completed/i })); fireEvent.click(screen.getByRole("button", { name: /completed/i }));
expect(onChangeStatus).toHaveBeenCalledWith("Completed"); expect(onRequestComplete).toHaveBeenCalled();
expect(onChangeStatus).not.toHaveBeenCalled();
}); });
}); });

View file

@ -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> = {}): 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();
});
});

View file

@ -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> = {}): 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(
<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 },
);
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(
<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,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(
<WorkOrderCompleteDialog
open
isPending={false}
onDialogClose={undefined}
onKeepOpen={onKeepOpen}
onConfirm={onConfirm}
/>,
{ 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(
<WorkOrderCompleteDialog
open
isPending={false}
onDialogClose={undefined}
onKeepOpen={vi.fn()}
onConfirm={onConfirm}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /mark as complete/i }));
expect(onConfirm).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();
});
});

View file

@ -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);
});
});