mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 11:33:12 +00:00
feat(work-orders): lock completed work orders with confirmation (SH-115)
Keep flag, comments, and extra docs mutable; require Mark as Complete confirmation.
This commit is contained in:
parent
c2a8cf28d6
commit
18d80a3c47
40 changed files with 925 additions and 96 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { DetailPageSkeleton } from "./_components/loading/detail-page-skeleton";
|
||||
|
|
@ -11,6 +12,7 @@ import { WorkOrderViewAccordions } from "./_components/view/work-order-view-acco
|
|||
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
|
||||
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
|
||||
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
|
||||
import { WorkOrderCompleteDialog } from "./_components/list/work-order-complete-dialog";
|
||||
import {
|
||||
detailToClosabilityInput,
|
||||
getClosabilityGaps,
|
||||
|
|
@ -22,6 +24,7 @@ export default function WorkOrderViewPage() {
|
|||
const { id } = useParams();
|
||||
const workOrderId = id ?? "";
|
||||
const view = useWorkOrderViewPage(workOrderId);
|
||||
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||
|
||||
if (view.isLoading && !view.wo) {
|
||||
return <DetailPageSkeleton variant="view" />;
|
||||
|
|
@ -48,6 +51,10 @@ export default function WorkOrderViewPage() {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
if (isCompletionStatus) {
|
||||
setConfirmComplete(true);
|
||||
return;
|
||||
}
|
||||
view.statusMutation.mutate(status as WorkOrderStatus);
|
||||
};
|
||||
|
||||
|
|
@ -116,6 +123,17 @@ export default function WorkOrderViewPage() {
|
|||
onSave={view.saveVendorEditor}
|
||||
onEditorChange={view.setVendorEditor}
|
||||
/>
|
||||
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={view.statusMutation.isPending}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
view.statusMutation.mutate("Done");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Text } from "@/components/ui/text";
|
|||
|
||||
type SlideOverFooterProps = {
|
||||
isCancelled: boolean;
|
||||
isCompleted?: boolean;
|
||||
editing: boolean;
|
||||
isDirty: boolean;
|
||||
showUnsaved: boolean;
|
||||
|
|
@ -19,6 +20,7 @@ type SlideOverFooterProps = {
|
|||
|
||||
export function SlideOverFooter({
|
||||
isCancelled,
|
||||
isCompleted = false,
|
||||
editing,
|
||||
isDirty,
|
||||
showUnsaved,
|
||||
|
|
@ -85,6 +87,10 @@ export function SlideOverFooter({
|
|||
);
|
||||
}
|
||||
|
||||
if (isCompleted && !editing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
||||
|
|
|
|||
|
|
@ -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 { 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 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 { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
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 = {
|
||||
source: WorkOrderTableRow;
|
||||
isCancelled: boolean;
|
||||
isCompleted?: boolean;
|
||||
dispatcher?: WorkOrderUser;
|
||||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
};
|
||||
|
||||
export function SlideOverInfoTabDisplayView({
|
||||
source,
|
||||
isCancelled,
|
||||
isCompleted = false,
|
||||
dispatcher,
|
||||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
}: SlideOverInfoTabDisplayViewProps) {
|
||||
const appt = parseApptTime(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>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Site">
|
||||
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
|
||||
{source.site || "—"}
|
||||
</span>
|
||||
{isCompleted ? (
|
||||
<SiteCell
|
||||
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 label="Type of WO">
|
||||
{source.type ? (
|
||||
|
|
@ -61,7 +94,17 @@ export function SlideOverInfoTabDisplayView({
|
|||
<span className="text-[12.5px]">{apptDisplay}</span>
|
||||
</SlideOverInfoRow>
|
||||
<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]">
|
||||
<PmTypeIcon pmType={source.pm} size={14} />
|
||||
{source.pm}
|
||||
|
|
@ -71,7 +114,19 @@ export function SlideOverInfoTabDisplayView({
|
|||
)}
|
||||
</SlideOverInfoRow>
|
||||
<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 label="Status">
|
||||
<SlideOverInfoTabStatusField source={source} />
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type SlideOverInfoTabEditViewProps = {
|
|||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
export function SlideOverInfoTabEditView({
|
||||
|
|
@ -38,6 +39,7 @@ export function SlideOverInfoTabEditView({
|
|||
allOrders,
|
||||
onDraftChange,
|
||||
onDuplicateFound,
|
||||
onRequestComplete,
|
||||
}: SlideOverInfoTabEditViewProps) {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -149,6 +151,7 @@ export function SlideOverInfoTabEditView({
|
|||
wo={draft}
|
||||
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Comp Doc">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type SlideOverInfoTabProps = {
|
|||
draft: WorkOrderTableRow;
|
||||
editing: boolean;
|
||||
isCancelled: boolean;
|
||||
isCompleted?: boolean;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
|
|
@ -19,6 +20,7 @@ type SlideOverInfoTabProps = {
|
|||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
export function SlideOverInfoTab({
|
||||
|
|
@ -26,6 +28,7 @@ export function SlideOverInfoTab({
|
|||
draft,
|
||||
editing,
|
||||
isCancelled,
|
||||
isCompleted = false,
|
||||
users,
|
||||
sites,
|
||||
vendors,
|
||||
|
|
@ -33,8 +36,9 @@ export function SlideOverInfoTab({
|
|||
allOrders,
|
||||
onDraftChange,
|
||||
onDuplicateFound,
|
||||
onRequestComplete,
|
||||
}: SlideOverInfoTabProps) {
|
||||
const isEditing = editing && !isCancelled;
|
||||
const isEditing = editing && !isCancelled && !isCompleted;
|
||||
const source = isEditing ? draft : row;
|
||||
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
||||
|
||||
|
|
@ -49,6 +53,7 @@ export function SlideOverInfoTab({
|
|||
allOrders={allOrders}
|
||||
onDraftChange={onDraftChange}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -57,7 +62,11 @@ export function SlideOverInfoTab({
|
|||
<SlideOverInfoTabDisplayView
|
||||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
isCompleted={isCompleted}
|
||||
dispatcher={dispatcher}
|
||||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import {
|
|||
buildSlideOverPatch,
|
||||
isSlideOverDraftDirty,
|
||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import {
|
||||
isWorkOrderCoreLocked,
|
||||
isWorkOrderFullyLocked,
|
||||
} from "@/domain/work-orders/utils/work-order-lock";
|
||||
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||
import type { SlideOverTab } from "./use-work-order-slide-over";
|
||||
|
||||
|
|
@ -49,7 +53,9 @@ export function useSlideOverEditState({
|
|||
}
|
||||
setDraft(row);
|
||||
setBaseline(row);
|
||||
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||
setEditing(
|
||||
editMode && !isWorkOrderFullyLocked(row.status) && !isWorkOrderCoreLocked(row.status),
|
||||
);
|
||||
setTab("info");
|
||||
setShowUnsaved(false);
|
||||
setUnsavedIntent(null);
|
||||
|
|
@ -139,6 +145,9 @@ export function useSlideOverEditState({
|
|||
|
||||
const startEditing = () => {
|
||||
if (!infoSource) return;
|
||||
if (isWorkOrderFullyLocked(infoSource.status) || isWorkOrderCoreLocked(infoSource.status)) {
|
||||
return;
|
||||
}
|
||||
setDraft(infoSource);
|
||||
setBaseline(infoSource);
|
||||
setEditing(true);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import {
|
|||
useUploadCompletionDoc,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
canMutateWorkOrderComments,
|
||||
canMutateWorkOrderExtraDocs,
|
||||
isWorkOrderCompleted,
|
||||
isWorkOrderFullyLocked,
|
||||
} from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
|
@ -59,8 +65,11 @@ export function useWorkOrderSlideOver({
|
|||
const detailForRow =
|
||||
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
|
||||
const infoSource = detailForRow?.info ?? row;
|
||||
const isCancelled = (infoSource?.status as string) === "Canceled";
|
||||
const isCancelled = isWorkOrderFullyLocked(infoSource?.status);
|
||||
const isCompleted = isWorkOrderCompleted(infoSource?.status);
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
||||
const isCommentsReadOnly = !canMutateWorkOrderComments(infoSource?.status);
|
||||
const isExtrasReadOnly = !canMutateWorkOrderExtraDocs(infoSource?.status);
|
||||
|
||||
const {
|
||||
completionMedia,
|
||||
|
|
@ -139,7 +148,10 @@ export function useWorkOrderSlideOver({
|
|||
detailForRow,
|
||||
infoSource,
|
||||
isCancelled,
|
||||
isCompleted,
|
||||
isDocumentsReadOnly,
|
||||
isCommentsReadOnly,
|
||||
isExtrasReadOnly,
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaPending,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type WorkOrderSlideOverTabsProps = {
|
|||
allOrders: WorkOrderTableRow[];
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onOpenCompletionDoc: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
export function WorkOrderSlideOverTabs({
|
||||
|
|
@ -39,6 +40,7 @@ export function WorkOrderSlideOverTabs({
|
|||
allOrders,
|
||||
onDuplicateFound,
|
||||
onOpenCompletionDoc,
|
||||
onRequestComplete,
|
||||
}: WorkOrderSlideOverTabsProps) {
|
||||
const hasError = Boolean(state.error) && !state.detailForRow;
|
||||
const showContent = Boolean(state.detailForRow || !state.isLoading);
|
||||
|
|
@ -52,6 +54,7 @@ export function WorkOrderSlideOverTabs({
|
|||
draft={activeDraft}
|
||||
editing={state.editing && !saving}
|
||||
isCancelled={state.isCancelled}
|
||||
isCompleted={state.isCompleted}
|
||||
users={users}
|
||||
sites={sites}
|
||||
vendors={vendors}
|
||||
|
|
@ -59,6 +62,7 @@ export function WorkOrderSlideOverTabs({
|
|||
allOrders={allOrders}
|
||||
onDraftChange={state.handleDraftChange}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -74,7 +78,7 @@ export function WorkOrderSlideOverTabs({
|
|||
{state.detailForRow != null && (
|
||||
<SlideOverCommentsTab
|
||||
comments={state.detailForRow.comments}
|
||||
isReadOnly={state.isDocumentsReadOnly}
|
||||
isReadOnly={state.isCommentsReadOnly}
|
||||
isPending={state.commentMutation.isPending}
|
||||
currentUserId={state.user?.id}
|
||||
isUpdatePending={state.updateCommentMutation.isPending}
|
||||
|
|
@ -142,7 +146,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
isReadOnly={state.isDocumentsReadOnly}
|
||||
isReadOnly={state.isExtrasReadOnly}
|
||||
pending={state.mediaPending}
|
||||
onUpload={(files) => {
|
||||
void state.uploadExtraFiles(files);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type WorkOrderSlideOverProps = {
|
|||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
||||
onCancelWO: (row: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
|
|
@ -47,6 +48,7 @@ export function WorkOrderSlideOver({
|
|||
onSave,
|
||||
onOpenCompletionDoc,
|
||||
onCancelWO,
|
||||
onRequestComplete,
|
||||
onDuplicateFound,
|
||||
onClose,
|
||||
saving,
|
||||
|
|
@ -85,10 +87,12 @@ export function WorkOrderSlideOver({
|
|||
allOrders={allOrders}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
||||
onRequestComplete={() => onRequestComplete?.(infoSource)}
|
||||
/>
|
||||
|
||||
<SlideOverFooter
|
||||
isCancelled={state.isCancelled}
|
||||
isCompleted={state.isCompleted}
|
||||
editing={state.editing}
|
||||
isDirty={state.isDirty}
|
||||
showUnsaved={state.showUnsaved}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type EditableWONumberCellProps = {
|
|||
allOrders: WorkOrderTableRow[];
|
||||
onSave: (v: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
function CounterPill({ label, count }: { label: string; count: number }) {
|
||||
|
|
@ -74,6 +75,7 @@ export function EditableWONumberCell({
|
|||
allOrders,
|
||||
onSave,
|
||||
onDuplicateFound,
|
||||
locked = false,
|
||||
}: EditableWONumberCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
|
@ -131,6 +133,29 @@ export function EditableWONumberCell({
|
|||
|
||||
const invalid = editing && draft.length > 0 && draft.length !== 11;
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editing ? (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type PMTypeCellProps = {
|
|||
extraServices?: string[];
|
||||
woNumber?: string;
|
||||
onSaveDetails?: (details: ServiceNotesDetails) => void;
|
||||
viewOnly?: boolean;
|
||||
};
|
||||
|
||||
export function PMTypeCell({
|
||||
|
|
@ -33,6 +34,7 @@ export function PMTypeCell({
|
|||
extraServices = [],
|
||||
woNumber,
|
||||
onSaveDetails,
|
||||
viewOnly = false,
|
||||
}: PMTypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
|
@ -52,7 +54,7 @@ export function PMTypeCell({
|
|||
};
|
||||
|
||||
const modal =
|
||||
onSaveDetails != null ? (
|
||||
onSaveDetails != null || viewOnly ? (
|
||||
<ServiceNotesDialog
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
|
|
@ -61,10 +63,44 @@ export function PMTypeCell({
|
|||
serviceNotes={serviceNotes ?? ""}
|
||||
woNumber={woNumber}
|
||||
pmTypes={pmTypes}
|
||||
onSave={onSaveDetails}
|
||||
viewOnly={viewOnly}
|
||||
onSave={onSaveDetails ?? (() => undefined)}
|
||||
/>
|
||||
) : 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) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type ServiceNotesDialogProps = {
|
|||
serviceNotes: string;
|
||||
woNumber?: string;
|
||||
pmTypes: string[];
|
||||
viewOnly?: boolean;
|
||||
onSave: (details: ServiceNotesDetails) => void;
|
||||
};
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ export function ServiceNotesDialog({
|
|||
serviceNotes,
|
||||
woNumber,
|
||||
pmTypes,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: ServiceNotesDialogProps) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
|
|
@ -76,38 +78,52 @@ export function ServiceNotesDialog({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ paddingTop: 8 }}
|
||||
>
|
||||
<WizardServiceNotesFields
|
||||
services={services}
|
||||
sel={sel}
|
||||
onToggle={toggle}
|
||||
note={note}
|
||||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
|
||||
<WizardServiceNotesFields
|
||||
services={services}
|
||||
sel={sel}
|
||||
onToggle={toggle}
|
||||
note={note}
|
||||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
pm: sel[0] ?? "",
|
||||
extraServices: sel.slice(1),
|
||||
serviceNotes: note,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{viewOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
pm: sel[0] ?? "",
|
||||
extraServices: sel.slice(1),
|
||||
serviceNotes: note,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type SiteCellProps = {
|
|||
q?: string;
|
||||
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||
createMode?: boolean;
|
||||
viewOnly?: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export function SiteCell({
|
|||
sites,
|
||||
q,
|
||||
createMode = false,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: SiteCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -99,6 +101,7 @@ export function SiteCell({
|
|||
additionalContacts={additionalContacts}
|
||||
sites={sites}
|
||||
createMode={createMode}
|
||||
viewOnly={viewOnly}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,20 @@ import { Button } from "@/components/ui/button";
|
|||
type SiteDialogFooterProps = {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
viewOnly?: boolean;
|
||||
};
|
||||
|
||||
export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) {
|
||||
export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
<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 (
|
||||
<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}>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type SiteDialogProps = {
|
|||
additionalContacts?: WorkOrderAdditionalContact[];
|
||||
sites: LocationOption[];
|
||||
createMode?: boolean;
|
||||
viewOnly?: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ export function SiteDialog({
|
|||
additionalContacts = [],
|
||||
sites,
|
||||
createMode = false,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: SiteDialogProps) {
|
||||
const s = useSiteDialogState({
|
||||
|
|
@ -78,7 +80,7 @@ export function SiteDialog({
|
|||
Select a site and edit its point of contact
|
||||
</Text>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
|
||||
<div>
|
||||
<FieldLabel>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
|
|
@ -129,9 +131,9 @@ export function SiteDialog({
|
|||
onPocNotesChange={s.setNotes}
|
||||
onAdditionalContactsChange={s.setExtraContacts}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<SiteDialogFooter onClose={s.close} onConfirm={s.attemptSave} />
|
||||
<SiteDialogFooter viewOnly={viewOnly} onClose={s.close} onConfirm={s.attemptSave} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type StatusCellProps = {
|
|||
onChangeStatus: (status: WizardWOStatus) => void;
|
||||
onOpenSlideOver: () => void;
|
||||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
function rowClosabilityGaps(wo: WorkOrderTableRow) {
|
||||
|
|
@ -102,6 +103,7 @@ export function StatusCell({
|
|||
onChangeStatus,
|
||||
onOpenSlideOver,
|
||||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
|
@ -154,6 +156,11 @@ export function StatusCell({
|
|||
if (isCompletedBlocked) {
|
||||
return;
|
||||
}
|
||||
if (status === "Completed" && wo.status !== "Completed") {
|
||||
onRequestComplete?.();
|
||||
setAnchorEl(null);
|
||||
return;
|
||||
}
|
||||
onChangeStatus(status);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type VendorCellProps = {
|
|||
techPhone?: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
q?: string;
|
||||
viewOnly?: boolean;
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ export function VendorCell({
|
|||
techPhone,
|
||||
vendors,
|
||||
q,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -60,6 +62,7 @@ export function VendorCell({
|
|||
tech={tech}
|
||||
techPhone={techPhone}
|
||||
vendors={vendors}
|
||||
viewOnly={viewOnly}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,25 @@ type VendorDialogFooterProps = {
|
|||
isPending: boolean;
|
||||
onCancel: () => 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 (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type VendorDialogProps = {
|
|||
tech: string;
|
||||
techPhone?: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
viewOnly?: boolean;
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ export function VendorDialog({
|
|||
tech,
|
||||
techPhone = "",
|
||||
vendors,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
|
|
@ -145,24 +147,27 @@ export function VendorDialog({
|
|||
Search and select the technician
|
||||
</Text>
|
||||
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={companyVendor ?? undefined}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
/>
|
||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<VendorDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
isPending={createVendor.isPending}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type UseWoTableRowHandlersOptions = {
|
|||
onNavigateToDay: (dateIso: string) => void;
|
||||
onOpenAdvancedFilters?: () => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -29,6 +30,7 @@ export function useWoTableRowHandlers({
|
|||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver,
|
||||
onRequestComplete,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
|
|
@ -110,6 +112,7 @@ export function useWoTableRowHandlers({
|
|||
onDuplicateFound?.(existing);
|
||||
},
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
||||
onRequestComplete,
|
||||
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
|
||||
onReorder: reorderRows,
|
||||
onDragStart: (id: string | number) => {
|
||||
|
|
@ -143,6 +146,7 @@ export function useWoTableRowHandlers({
|
|||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver,
|
||||
onRequestComplete,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type WoTableInlineRowCellsProps = {
|
|||
woShowError: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
function InlineTypeSeverityCell({
|
||||
|
|
@ -130,6 +131,7 @@ export function WoTableInlineRowCells({
|
|||
woShowError,
|
||||
onPatch,
|
||||
onDuplicateFound,
|
||||
onRequestComplete,
|
||||
}: WoTableInlineRowCellsProps) {
|
||||
const draftRow = toInlineDraftRow(draft);
|
||||
|
||||
|
|
@ -213,6 +215,7 @@ export function WoTableInlineRowCells({
|
|||
wo={draftRow as never}
|
||||
onChangeStatus={(status) => onPatch({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Flag } from "lucide-react";
|
||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||
|
||||
import type {
|
||||
LocationOption,
|
||||
|
|
@ -73,6 +74,7 @@ export function WoTableInlineRow({
|
|||
const missing = getInlineCreateMissing(draft);
|
||||
const canSave = !saving && missing.length === 0;
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||
const siteCellRef = useRef<HTMLTableCellElement>(null);
|
||||
const woCellRef = useRef<HTMLTableCellElement>(null);
|
||||
|
||||
|
|
@ -136,6 +138,7 @@ export function WoTableInlineRow({
|
|||
woShowError={woShowError}
|
||||
onPatch={patch}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
onRequestComplete={() => setConfirmComplete(true)}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 4px" }}>
|
||||
|
|
@ -146,6 +149,16 @@ export function WoTableInlineRow({
|
|||
onSave={attemptSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
patch({ status: "Completed" });
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type WoTableRowCellsProps = {
|
|||
forceScheduleHighlight?: boolean;
|
||||
forceDueDateHighlight?: boolean;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
viewOnly?: boolean;
|
||||
canceledLockStyle?: CSSProperties;
|
||||
handlers: WoTableRowHandlers;
|
||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
};
|
||||
|
|
@ -33,6 +35,8 @@ export function WoTableRowCells({
|
|||
forceScheduleHighlight,
|
||||
forceDueDateHighlight,
|
||||
mutationCellStyle,
|
||||
viewOnly = false,
|
||||
canceledLockStyle,
|
||||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowCellsProps) {
|
||||
|
|
@ -40,7 +44,7 @@ export function WoTableRowCells({
|
|||
<>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
style={{ padding: "0 14px", ...canceledLockStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SiteCell
|
||||
|
|
@ -55,6 +59,7 @@ export function WoTableRowCells({
|
|||
additionalContacts={row.additionalContacts}
|
||||
sites={sites}
|
||||
q={search}
|
||||
viewOnly={viewOnly}
|
||||
onSave={(p) =>
|
||||
onPatchRow({
|
||||
site: p.site,
|
||||
|
|
@ -79,6 +84,7 @@ export function WoTableRowCells({
|
|||
q={search}
|
||||
currentId={row.id}
|
||||
allOrders={allRows}
|
||||
locked={viewOnly}
|
||||
onSave={(v) => onPatchRow({ woNumber: v })}
|
||||
onDuplicateFound={handlers.onDuplicateFound}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type WoTableRowServiceCellsProps = {
|
|||
pmTypes: string[];
|
||||
search?: string;
|
||||
mutationCellStyle?: CSSProperties;
|
||||
viewOnly?: boolean;
|
||||
canceledLockStyle?: CSSProperties;
|
||||
handlers: WoTableRowHandlers;
|
||||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
};
|
||||
|
|
@ -29,6 +31,8 @@ export function WoTableRowServiceCells({
|
|||
pmTypes,
|
||||
search,
|
||||
mutationCellStyle,
|
||||
viewOnly = false,
|
||||
canceledLockStyle,
|
||||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowServiceCellsProps) {
|
||||
|
|
@ -36,7 +40,7 @@ export function WoTableRowServiceCells({
|
|||
<>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
style={{ padding: "0 14px", ...canceledLockStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PMTypeCell
|
||||
|
|
@ -46,6 +50,7 @@ export function WoTableRowServiceCells({
|
|||
serviceNotes={row.serviceNotes}
|
||||
extraServices={row.extraServices}
|
||||
woNumber={row.woNumber}
|
||||
viewOnly={viewOnly}
|
||||
onSave={(pm) => onPatchRow({ pm })}
|
||||
onSaveDetails={(d) =>
|
||||
onPatchRow({
|
||||
|
|
@ -58,7 +63,7 @@ export function WoTableRowServiceCells({
|
|||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
style={{ padding: "0 14px", ...canceledLockStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VendorCell
|
||||
|
|
@ -68,6 +73,7 @@ export function WoTableRowServiceCells({
|
|||
techPhone={row.techPhone}
|
||||
vendors={vendors}
|
||||
q={search}
|
||||
viewOnly={viewOnly}
|
||||
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
|
||||
/>
|
||||
</td>
|
||||
|
|
@ -88,6 +94,7 @@ export function WoTableRowServiceCells({
|
|||
onChangeStatus={(status) => onPatchRow({ status })}
|
||||
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
|
||||
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
|
||||
onRequestComplete={() => handlers.onRequestComplete?.(row)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
import {
|
||||
canApplyWorkOrderTablePatch,
|
||||
canMutateWorkOrderFlag,
|
||||
isWorkOrderCoreLocked,
|
||||
isWorkOrderFullyLocked,
|
||||
} from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { FlagCell } from "./cells/flag-cell";
|
||||
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||
import { WoTableRowCells } from "./wo-table-row-cells";
|
||||
|
|
@ -28,6 +34,7 @@ export type WoTableRowHandlers = {
|
|||
onScheduleWeek: (id: string | number, week: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
onPastDueAttempt?: (id: string | number) => void;
|
||||
onReorder: (dragId: string | number, overId: string | number) => void;
|
||||
onDragStart: (id: string | number) => void;
|
||||
|
|
@ -71,15 +78,33 @@ export function WoTableRow({
|
|||
}: WoTableRowProps) {
|
||||
const isDragging = dragViz.dragId === row.id;
|
||||
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
|
||||
const isCanceled = (row.status as string) === "Canceled";
|
||||
const isCanceled = isWorkOrderFullyLocked(row.status);
|
||||
const isCoreLocked = isWorkOrderCoreLocked(row.status);
|
||||
const closedForEdit = isCanceled || isCoreLocked;
|
||||
|
||||
const guardedHandlers: WoTableRowHandlers = {
|
||||
...handlers,
|
||||
onPatch: (id, patch) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
};
|
||||
|
||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||
if (isCanceled) return;
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(row.id, patch);
|
||||
};
|
||||
|
||||
/** Block mutation controls on canceled rows while keeping row/View clickable. */
|
||||
const mutationCellStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
|
||||
const coreLockStyle = closedForEdit ? MUTATION_CELL_STYLE : undefined;
|
||||
const canceledLockStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -122,11 +147,7 @@ export function WoTableRow({
|
|||
}}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: "0 0 0 6px",
|
||||
boxShadow: resolveFirstCellShadow(row),
|
||||
...mutationCellStyle,
|
||||
}}
|
||||
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
|
|
@ -139,14 +160,11 @@ export function WoTableRow({
|
|||
<GripVertical size={14} />
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{ padding: "0 2px 0 8px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<FlagCell
|
||||
color={row.flagColor}
|
||||
disabled={isCanceled}
|
||||
onChange={(c) => handlers.onPatch(row.id, { flagColor: c })}
|
||||
disabled={!canMutateWorkOrderFlag(row.status)}
|
||||
onChange={(c) => guardedHandlers.onPatch(row.id, { flagColor: c })}
|
||||
/>
|
||||
</td>
|
||||
<WoTableRowCells
|
||||
|
|
@ -158,8 +176,10 @@ export function WoTableRow({
|
|||
search={search}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={mutationCellStyle}
|
||||
handlers={handlers}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
/>
|
||||
<WoTableRowServiceCells
|
||||
|
|
@ -167,14 +187,16 @@ export function WoTableRow({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={mutationCellStyle}
|
||||
handlers={handlers}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
/>
|
||||
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<WoTableRowActions
|
||||
onView={() => handlers.onView(row)}
|
||||
onEdit={isCanceled ? undefined : () => handlers.onEdit(row)}
|
||||
onEdit={closedForEdit ? undefined : () => handlers.onEdit(row)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type WoTableProps = {
|
|||
weekMonday: string;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -97,6 +98,7 @@ export function WoTable({
|
|||
weekMonday,
|
||||
onNavigateToDay,
|
||||
onOpenSlideOver,
|
||||
onRequestComplete,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
|
|
@ -121,6 +123,7 @@ export function WoTable({
|
|||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver,
|
||||
onRequestComplete,
|
||||
onOpenCompDoc,
|
||||
onOpenUplifts,
|
||||
onOpenMedia,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/det
|
|||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
|
||||
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||
|
|
@ -58,6 +59,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||
onCancelWO={page.setConfirmCancel}
|
||||
onRequestComplete={page.setConfirmComplete}
|
||||
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||
saving={page.isPatchPending}
|
||||
onClose={page.handleCloseSlideOver}
|
||||
|
|
@ -71,6 +73,14 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
onConfirm={page.handleConfirmCancel}
|
||||
/>
|
||||
|
||||
<WorkOrderCompleteDialog
|
||||
open={Boolean(page.confirmComplete)}
|
||||
isPending={page.isPatchPending}
|
||||
onDialogClose={page.completeDialogClose}
|
||||
onKeepOpen={() => page.setConfirmComplete(null)}
|
||||
onConfirm={page.handleConfirmComplete}
|
||||
/>
|
||||
|
||||
{page.activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={page.activeMediaRow}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -134,6 +136,7 @@ export function WizardStepVendorTime({
|
|||
onUpdate,
|
||||
onPatch,
|
||||
}: WizardStepVendorTimeProps) {
|
||||
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||
|
|
@ -264,10 +267,24 @@ export function WizardStepVendorTime({
|
|||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
onPick={(status) => {
|
||||
if (status === "Completed" && closabilityGaps.length > 0) return;
|
||||
if (status === "Completed" && draft.status !== "Completed") {
|
||||
setConfirmComplete(true);
|
||||
return;
|
||||
}
|
||||
onUpdate("status", status as WizardWOStatus);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
onUpdate("status", "Completed");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export function useWorkOrdersListPage() {
|
|||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
|
||||
|
|
@ -86,7 +87,14 @@ export function useWorkOrdersListPage() {
|
|||
handleCloseSlideOver();
|
||||
};
|
||||
|
||||
const handleConfirmComplete = () => {
|
||||
if (!confirmComplete) return;
|
||||
patchField(confirmComplete.id, { status: "Completed" });
|
||||
setConfirmComplete(null);
|
||||
};
|
||||
|
||||
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
|
||||
const completeDialogClose = guardDialogClose(isPatchPending, () => setConfirmComplete(null));
|
||||
|
||||
const saveCompletionDoc = async (
|
||||
row: WorkOrderTableRow,
|
||||
|
|
@ -109,6 +117,8 @@ export function useWorkOrdersListPage() {
|
|||
slideOverEdit,
|
||||
confirmCancel,
|
||||
setConfirmCancel,
|
||||
confirmComplete,
|
||||
setConfirmComplete,
|
||||
docRow,
|
||||
setDocRow,
|
||||
mediaRow,
|
||||
|
|
@ -133,7 +143,9 @@ export function useWorkOrdersListPage() {
|
|||
handleOpenSlideOver,
|
||||
handleCloseSlideOver,
|
||||
handleConfirmCancel,
|
||||
handleConfirmComplete,
|
||||
cancelDialogClose,
|
||||
completeDialogClose,
|
||||
saveCompletionDoc,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export default function WorkOrdersListPage() {
|
|||
weekMonday={filters.weekMonday}
|
||||
onNavigateToDay={filters.navigateToDay}
|
||||
onOpenSlideOver={page.handleOpenSlideOver}
|
||||
onRequestComplete={page.setConfirmComplete}
|
||||
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||
onOpenMedia={page.setMediaRow}
|
||||
onOpenUplifts={page.setUpliftsRow}
|
||||
|
|
|
|||
53
src/domain/work-orders/utils/work-order-lock.ts
Normal file
53
src/domain/work-orders/utils/work-order-lock.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export function isWorkOrderCanceled(status: string | null | undefined): boolean {
|
||||
return status === "Canceled" || status === "Cancelled";
|
||||
}
|
||||
|
||||
export function isWorkOrderCompleted(status: string | null | undefined): boolean {
|
||||
return status === "Completed" || status === "Done";
|
||||
}
|
||||
|
||||
export function isWorkOrderCoreLocked(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCompleted(status);
|
||||
}
|
||||
|
||||
export function isWorkOrderFullyLocked(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCanceled(status);
|
||||
}
|
||||
|
||||
export function canMutateWorkOrderFlag(status: string | null | undefined): boolean {
|
||||
return !isWorkOrderFullyLocked(status);
|
||||
}
|
||||
|
||||
export function canMutateWorkOrderComments(status: string | null | undefined): boolean {
|
||||
return !isWorkOrderFullyLocked(status);
|
||||
}
|
||||
|
||||
export function canMutateWorkOrderExtraDocs(status: string | null | undefined): boolean {
|
||||
return !isWorkOrderFullyLocked(status);
|
||||
}
|
||||
|
||||
const COMPLETED_BOARD_PATCH_KEYS = new Set(["flagColor"]);
|
||||
|
||||
/**
|
||||
* Board PATCH policy for SH-115. Comments and Extra Docs use dedicated endpoints
|
||||
* (`canMutateWorkOrderComments` / `canMutateWorkOrderExtraDocs`). Those requests
|
||||
* may still fail with 422 `ReadOnly` if the backend lock remains total.
|
||||
*/
|
||||
export function canApplyWorkOrderTablePatch(
|
||||
status: string | null | undefined,
|
||||
patch: Record<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);
|
||||
}
|
||||
|
|
@ -29,9 +29,11 @@ export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): str
|
|||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
||||
/** Backend IsReadOnly: Canceled or Completed. */
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return status === "Canceled" || status === "Completed";
|
||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||
}
|
||||
|
||||
export function canDeleteWorkOrderMedia(media: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
|
||||
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||
import {
|
||||
canMutateWorkOrderComments,
|
||||
canMutateWorkOrderExtraDocs,
|
||||
} from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
describe("Completed comments and extra docs remain writable", () => {
|
||||
it("shows the comment composer when status is Completed", () => {
|
||||
renderWithProviders(
|
||||
<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("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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
describe("SlideOverFooter completed lock", () => {
|
||||
it("hides Edit and Cancel WO when the work order is completed", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SlideOverFooter
|
||||
isCancelled={false}
|
||||
isCompleted
|
||||
editing={false}
|
||||
isDirty={false}
|
||||
showUnsaved={false}
|
||||
onCancelWO={vi.fn()}
|
||||
onCancelEdit={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
onKeepEditing={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByRole("button", { name: /edit wo/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/cancel wo/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -106,7 +106,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("allows Completed when no pending uplift", () => {
|
||||
it("does not patch Completed when confirmation callback is omitted", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
|
|
@ -117,7 +117,28 @@ describe("StatusCell pending uplift closability", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows Completed when no pending uplift", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
const onRequestComplete = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<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", () => {
|
||||
|
|
@ -143,12 +164,14 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
it("allows Completed when vendorId is set even if company label is empty", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
const onRequestComplete = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ company: "", vendorId: "7", tech: "" })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
|
@ -156,17 +179,20 @@ describe("StatusCell pending uplift closability", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
expect(onRequestComplete).toHaveBeenCalled();
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows Completed when vendorId has an empty company label and a live dispatch status", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
const onRequestComplete = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ company: "", vendorId: "7", tech: "", primaryDispatchStatus: "Sent" })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
|
@ -174,7 +200,8 @@ describe("StatusCell pending uplift closability", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
expect(onRequestComplete).toHaveBeenCalled();
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["Cancelled", "Canceled", "Refused"] as const)(
|
||||
|
|
@ -232,6 +259,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
it("allows Completed after a vendor patch clears leftover refused status", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
const onRequestComplete = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
|
|
@ -243,6 +271,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
})}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
|
@ -250,17 +279,20 @@ describe("StatusCell pending uplift closability", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
expect(onRequestComplete).toHaveBeenCalled();
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows Completed when technician is empty if company is set", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
const onRequestComplete = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<StatusCell
|
||||
wo={makeRow({ tech: "" })}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
|
@ -268,6 +300,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: /in progress/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /completed/i }));
|
||||
|
||||
expect(onChangeStatus).toHaveBeenCalledWith("Completed");
|
||||
expect(onRequestComplete).toHaveBeenCalled();
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
49
src/test/domain/work-orders/utils/work-order-lock.test.ts
Normal file
49
src/test/domain/work-orders/utils/work-order-lock.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue