mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 05:22:04 +00:00
fix(work-orders): lock completed status and legacy detail
Keep Flag, comments, and extra docs writable after completion, and hide the completed status menu.
This commit is contained in:
parent
260fe6c654
commit
d9d6334f57
17 changed files with 607 additions and 202 deletions
|
|
@ -18,6 +18,10 @@ import {
|
||||||
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() {
|
||||||
|
|
@ -42,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 &&
|
||||||
|
|
@ -68,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
|
||||||
|
|
@ -75,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}
|
||||||
/>
|
/>
|
||||||
|
|
@ -117,6 +132,7 @@ 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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -162,10 +162,7 @@ export function useSlideOverEditState({
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (!infoSource) return;
|
if (!infoSource || isSlideOverInfoLocked(infoSource.status)) return;
|
||||||
if (isSlideOverInfoLocked(infoSource.status)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraft(infoSource);
|
setDraft(infoSource);
|
||||||
setBaseline(infoSource);
|
setBaseline(infoSource);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ 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";
|
||||||
|
|
@ -36,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,
|
||||||
|
|
@ -60,16 +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 = isWorkOrderFullyLocked(infoSource?.status);
|
|
||||||
const isCompleted = isWorkOrderCompleted(infoSource?.status);
|
|
||||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
|
||||||
const isCommentsReadOnly = !canMutateWorkOrderComments(infoSource?.status);
|
|
||||||
const isExtrasReadOnly = !canMutateWorkOrderExtraDocs(infoSource?.status);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
completionMedia,
|
completionMedia,
|
||||||
|
|
@ -101,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,
|
||||||
|
|
@ -147,11 +188,7 @@ export function useWorkOrderSlideOver({
|
||||||
setTab,
|
setTab,
|
||||||
detailForRow,
|
detailForRow,
|
||||||
infoSource,
|
infoSource,
|
||||||
isCancelled,
|
...readFlags,
|
||||||
isCompleted,
|
|
||||||
isDocumentsReadOnly,
|
|
||||||
isCommentsReadOnly,
|
|
||||||
isExtrasReadOnly,
|
|
||||||
completionMedia,
|
completionMedia,
|
||||||
extrasUploads,
|
extrasUploads,
|
||||||
mediaPending,
|
mediaPending,
|
||||||
|
|
|
||||||
|
|
@ -15,14 +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;
|
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));
|
||||||
}
|
}
|
||||||
|
|
@ -104,10 +123,15 @@ export function StatusCell({
|
||||||
onOpenSlideOver,
|
onOpenSlideOver,
|
||||||
onPastDueAttempt,
|
onPastDueAttempt,
|
||||||
onRequestComplete,
|
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} />
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ 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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type WorkOrderVendorEditorDialogProps = {
|
||||||
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 +32,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">
|
||||||
|
|
@ -53,6 +55,7 @@ export function WorkOrderVendorEditorDialog({
|
||||||
<TextField
|
<TextField
|
||||||
label="Technician name"
|
label="Technician name"
|
||||||
value={editor?.contactName ?? ""}
|
value={editor?.contactName ?? ""}
|
||||||
|
disabled={viewOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onEditorChange((current) =>
|
onEditorChange((current) =>
|
||||||
current ? { ...current, contactName: event.target.value } : current,
|
current ? { ...current, contactName: event.target.value } : current,
|
||||||
|
|
@ -63,6 +66,7 @@ export function WorkOrderVendorEditorDialog({
|
||||||
<TextField
|
<TextField
|
||||||
label="Phone"
|
label="Phone"
|
||||||
value={editor?.phone ?? ""}
|
value={editor?.phone ?? ""}
|
||||||
|
disabled={viewOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onEditorChange((current) =>
|
onEditorChange((current) =>
|
||||||
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
current ? { ...current, phone: formatPhoneMask(event.target.value) } : current,
|
||||||
|
|
@ -75,6 +79,7 @@ export function WorkOrderVendorEditorDialog({
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={editor?.email ?? ""}
|
value={editor?.email ?? ""}
|
||||||
|
disabled={viewOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onEditorChange((current) =>
|
onEditorChange((current) =>
|
||||||
current ? { ...current, email: event.target.value } : current,
|
current ? { ...current, email: event.target.value } : current,
|
||||||
|
|
@ -86,6 +91,7 @@ export function WorkOrderVendorEditorDialog({
|
||||||
multiline
|
multiline
|
||||||
minRows={3}
|
minRows={3}
|
||||||
value={editor?.notes ?? ""}
|
value={editor?.notes ?? ""}
|
||||||
|
disabled={viewOnly}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onEditorChange((current) =>
|
onEditorChange((current) =>
|
||||||
current ? { ...current, notes: event.target.value } : current,
|
current ? { ...current, notes: event.target.value } : current,
|
||||||
|
|
@ -98,16 +104,22 @@ export function WorkOrderVendorEditorDialog({
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={isSaving}>
|
{viewOnly ? (
|
||||||
Cancel
|
<Button onClick={onClose}>Close</Button>
|
||||||
</Button>
|
) : (
|
||||||
<Button
|
<>
|
||||||
variant="contained"
|
<Button onClick={onClose} disabled={isSaving}>
|
||||||
onClick={onSave}
|
Cancel
|
||||||
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
</Button>
|
||||||
>
|
<Button
|
||||||
{isSaving ? "Saving..." : "Save"}
|
variant="contained"
|
||||||
</Button>
|
onClick={onSave}
|
||||||
|
disabled={!editor || loading || isSaving || !isVendorEditorValid(editor)}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -117,6 +117,60 @@ function WizardVendorDetail({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardStepVendorTimeProps) {
|
||||||
|
const duration = calcDuration(draft.apptStart, draft.apptEnd);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WizLabel optional>Appt Time</WizLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={draft.apptStart}
|
||||||
|
placeholder="Start"
|
||||||
|
icon={
|
||||||
|
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
}
|
||||||
|
search={false}
|
||||||
|
options={TIME_SLOTS.map((t) => ({ key: t, label: fmt12(t) }))}
|
||||||
|
onPick={(t) =>
|
||||||
|
onPatch({
|
||||||
|
apptStart: t,
|
||||||
|
apptEnd: draft.apptEnd && draft.apptEnd <= t ? "" : draft.apptEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
to
|
||||||
|
</span>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={draft.apptEnd}
|
||||||
|
placeholder="End"
|
||||||
|
icon={
|
||||||
|
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
}
|
||||||
|
search={false}
|
||||||
|
disabled={!draft.apptStart}
|
||||||
|
options={TIME_SLOTS.filter((t) => !draft.apptStart || t > draft.apptStart).map((t) => ({
|
||||||
|
key: t,
|
||||||
|
label: fmt12(t),
|
||||||
|
}))}
|
||||||
|
onPick={(t) => onUpdate("apptEnd", t)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Boolean(draft.apptStart && draft.apptEnd && duration) && (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="caption"
|
||||||
|
className="mt-2 flex items-center gap-1.5"
|
||||||
|
sx={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Clock size={12} /> Duration {duration}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
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;
|
||||||
|
|
@ -207,55 +261,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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]);
|
||||||
/**
|
/**
|
||||||
* Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use
|
* Board PATCH policy for Completed: only `flagColor`. Comments and Extra Docs use
|
||||||
* dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`).
|
* dedicated endpoints (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`).
|
||||||
* Those requests may still fail with 422 `ReadOnly` if the backend lock remains total.
|
|
||||||
*/
|
*/
|
||||||
export function canApplyWorkOrderTablePatch(
|
export function canApplyWorkOrderTablePatch(
|
||||||
status: string | null | undefined,
|
status: string | null | undefined,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,33 @@ describe("Completed comments and extra docs remain writable", () => {
|
||||||
expect(screen.getByRole("button", { name: /post/i })).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", () => {
|
it("hides the comment composer when status is Canceled", () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<SlideOverCommentsTab
|
<SlideOverCommentsTab
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,25 @@ describe("StatusCell pending uplift closability", () => {
|
||||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not open the status menu when readOnly", () => {
|
||||||
|
const onChangeStatus = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StatusCell
|
||||||
|
wo={makeRow({ status: "Completed" })}
|
||||||
|
readOnly
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
onOpenSlideOver={vi.fn()}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Status is locked because this work order is completed"));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /scheduled/i })).not.toBeInTheDocument();
|
||||||
|
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows Completed when technician is empty if company is set", () => {
|
it("allows Completed when technician is empty if company is set", () => {
|
||||||
const onChangeStatus = vi.fn();
|
const onChangeStatus = vi.fn();
|
||||||
const onRequestComplete = vi.fn();
|
const onRequestComplete = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -104,4 +104,33 @@ describe("WoTableRow completed selective lock", () => {
|
||||||
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
|
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTitle("View details")).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue