mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 22:43:23 +00:00
Merge pull request #165 from Sea-Haven-Industries/feat/SH-169-wo-inline-lifecycle
feat(work-orders): align inline lifecycle transitions with AAP
This commit is contained in:
commit
7cc7cc19d0
18 changed files with 781 additions and 131 deletions
|
|
@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
export function buildInlineDraftCreateInput(
|
||||
draft: WorkOrderTableDraft,
|
||||
|
|
@ -36,7 +37,11 @@ export function buildInlineDraftCreateInput(
|
|||
vendorNotes: "",
|
||||
apptStart: draft.apptStart,
|
||||
apptEnd: draft.apptEnd,
|
||||
status: draft.status || "Incomplete",
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import { useState } from "react";
|
|||
import { AlertTriangle, Check } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
PAST_DUE_BADGE_TOOLTIP,
|
||||
PAST_DUE_POPOVER_BODY,
|
||||
PAST_DUE_RESCHEDULE_CTA,
|
||||
getStatusOptionState,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
|
|
@ -24,6 +27,7 @@ type StatusCellProps = {
|
|||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
readOnly?: boolean;
|
||||
statusOptions?: readonly WizardWOStatus[];
|
||||
};
|
||||
|
||||
function ReadOnlyStatusLabel({ status }: { status: string }) {
|
||||
|
|
@ -64,7 +68,7 @@ function PastDueStatusCell({
|
|||
onPastDueAttempt?.();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
title={PAST_DUE_BADGE_TOOLTIP}
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -97,7 +101,7 @@ function PastDueStatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
{PAST_DUE_POPOVER_BODY}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -108,7 +112,7 @@ function PastDueStatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
{PAST_DUE_RESCHEDULE_CTA}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,6 +128,7 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
readOnly = false,
|
||||
statusOptions = BOARD_INLINE_STATUSES,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
|
@ -162,22 +167,19 @@ export function StatusCell({
|
|||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
{statusOptions.map((status) => {
|
||||
const option = getStatusOptionState(wo, status, closabilityGaps);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
disabled={option.disabled}
|
||||
title={option.title}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
if (status === "Completed" && wo.status !== "Completed") {
|
||||
|
|
@ -189,9 +191,9 @@ export function StatusCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
option.disabled
|
||||
? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
|||
dispatcherId: draft.dispatcherId,
|
||||
dispatcherName: "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||
vendorId: draft.vendorId,
|
||||
completedDate: "",
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
|
|||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
|
|
@ -84,30 +88,36 @@ function InlineScheduleOnCell({
|
|||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
onSave={(date) =>
|
||||
onPatch({
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -215,6 +225,7 @@ export function WoTableInlineRowCells({
|
|||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
onChangeStatus={(status) => onPatch({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
|
|
@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
|
||||
onClear={
|
||||
hasWizardSchedule(draft)
|
||||
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
|
|
@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onChange={(iso) =>
|
||||
onPatch({
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
onPatch({
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
onPatch({
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
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";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
TIME_SLOTS,
|
||||
WIZARD_STATUS_DOTS,
|
||||
type WizardWOStatus,
|
||||
|
|
@ -15,12 +13,15 @@ import {
|
|||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
getStatusOptionState,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
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,
|
||||
|
|
@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
|
|||
);
|
||||
}
|
||||
|
||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
||||
return ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
|
||||
return CREATION_INLINE_STATUSES.map((status) => {
|
||||
const option = getStatusOptionState(draft, status, closabilityGaps);
|
||||
return {
|
||||
key: status,
|
||||
label: status,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
|
||||
disabled: isCompletedBlocked,
|
||||
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
|
||||
disabled: option.disabled,
|
||||
title: option.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -192,7 +193,6 @@ 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;
|
||||
|
|
@ -272,27 +272,16 @@ export function WizardStepVendorTime({
|
|||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
options={buildWizardStatusOptions(draft, closabilityGaps)}
|
||||
onPick={(status) => {
|
||||
if (status === "Completed" && closabilityGaps.length > 0) return;
|
||||
if (status === "Completed" && draft.status !== "Completed") {
|
||||
setConfirmComplete(true);
|
||||
const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
onUpdate("status", status as WizardWOStatus);
|
||||
onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
onUpdate("status", "Completed");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,15 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import {
|
||||
maybeAutoSchedulePatch,
|
||||
mergeAutoSchedulePatch,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export { maybeAutoSchedulePatch };
|
||||
|
||||
export type { WorkOrderTablePatch };
|
||||
|
||||
export type WorkOrderTablePatchOptions = {
|
||||
|
|
@ -124,10 +130,16 @@ export function useWorkOrderTableMutations(
|
|||
|
||||
const patchMutation = useMutation({
|
||||
meta: { suppressErrorToast: true },
|
||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
mutationFn: ({
|
||||
id,
|
||||
persistPatch,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
persistPatch: WorkOrderTablePatch;
|
||||
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
|
||||
onMutate: async ({ id, persistPatch }) => {
|
||||
onPatch(id, persistPatch);
|
||||
},
|
||||
onSuccess: (result, { id }) => {
|
||||
if (result.kind === "board") {
|
||||
|
|
@ -158,15 +170,21 @@ export function useWorkOrderTableMutations(
|
|||
},
|
||||
});
|
||||
|
||||
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
mergeAutoSchedulePatch(getRow?.(id), patch);
|
||||
|
||||
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
|
||||
void patchMutation.mutateAsync({ id, patch }).then(
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
|
||||
() => options?.onSuccess?.(),
|
||||
(error: Error) => options?.onError?.(error),
|
||||
);
|
||||
};
|
||||
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
patchMutation.mutateAsync({ id, patch });
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch });
|
||||
};
|
||||
|
||||
return {
|
||||
patchField,
|
||||
|
|
@ -189,19 +207,9 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
|
|||
);
|
||||
}
|
||||
|
||||
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||
const hasDate = !!row.scheduledOn;
|
||||
const hasDispatcher = !!row.dispatcherId;
|
||||
if (hasDate && hasDispatcher && row.status === "Incomplete") {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schedule table patch. Clearing the schedule optimistically clears
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
|
||||
*/
|
||||
export function applySchedulePatch(
|
||||
scheduledOn: string,
|
||||
|
|
|
|||
|
|
@ -26,11 +26,19 @@ export type AuditActionRuleContext = {
|
|||
|
||||
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||
|
||||
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
function tryStatusRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
if (oldValue.text) {
|
||||
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
PENDING_UPLIFT_GAP,
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
|
||||
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
|
||||
|
||||
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
|
||||
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
];
|
||||
|
||||
export const SCHEDULED_NEEDS_DATE_MSG =
|
||||
"Set a Schedule On date before marking this work order as Scheduled.";
|
||||
|
||||
export const PAST_DUE_BADGE_TOOLTIP =
|
||||
"This WO missed its service window. Reschedule to unlock status updates.";
|
||||
|
||||
export const PAST_DUE_POPOVER_BODY =
|
||||
"This work order is past due. A new date must be scheduled before the status can be updated.";
|
||||
|
||||
export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
|
||||
|
||||
export const COMPLETED_DOC_BLOCK_MSG =
|
||||
"Generate or mark the completion document as not required before completing this work order";
|
||||
|
||||
export const COMPLETED_MEDIA_BLOCK_MSG =
|
||||
"Upload at least one completion photo before completing this work order.";
|
||||
|
||||
const COMPLETE_ACTION = "completing this work order";
|
||||
|
||||
const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
|
||||
|
||||
const PRE_SCHEDULE_STATUSES = new Set<string>(["Incomplete", "Pending", ""]);
|
||||
|
||||
const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
|
||||
["Dispatcher", "Assigned To"],
|
||||
["Company", "Vendor"],
|
||||
["Completed Date", "Completed Date"],
|
||||
["Service", "Service"],
|
||||
];
|
||||
|
||||
export type ScheduleDateInput = {
|
||||
scheduledOn?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
schedMode?: string;
|
||||
};
|
||||
|
||||
export type StatusOptionState = {
|
||||
disabled: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function isLifecycleFieldEmpty(value?: string): boolean {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return EMPTY_FIELD_TOKENS.has(trimmed);
|
||||
}
|
||||
|
||||
/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
|
||||
export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
|
||||
if (row.scheduleWeekOnly === true || row.schedMode === "week") {
|
||||
return false;
|
||||
}
|
||||
return !isLifecycleFieldEmpty(row.scheduledOn);
|
||||
}
|
||||
|
||||
export function isPreScheduleStatus(status: string): boolean {
|
||||
return PRE_SCHEDULE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
|
||||
if (gaps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
|
||||
([, label]) => label,
|
||||
);
|
||||
|
||||
if (fields.length === 1 && fields[0] === "Service") {
|
||||
return `Select a service before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes("WO number")) {
|
||||
return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||
}
|
||||
if (gaps.includes("Completion document")) {
|
||||
return COMPLETED_DOC_BLOCK_MSG;
|
||||
}
|
||||
if (gaps.includes("At least 1 photo")) {
|
||||
return COMPLETED_MEDIA_BLOCK_MSG;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStatusOptionState(
|
||||
row: ScheduleDateInput,
|
||||
status: WizardWOStatus,
|
||||
closabilityGaps: string[],
|
||||
): StatusOptionState {
|
||||
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
|
||||
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
|
||||
}
|
||||
if (status === "Completed") {
|
||||
const title = formatAapCompletedBlockTitle(closabilityGaps);
|
||||
if (title) {
|
||||
return { disabled: true, title };
|
||||
}
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
|
||||
export function maybeAutoSchedulePatch(
|
||||
row: ScheduleDateInput & { status?: string },
|
||||
): WorkOrderTablePatch {
|
||||
const hasDate = hasConcreteScheduleDate(row);
|
||||
if (hasDate && isPreScheduleStatus(row.status ?? "")) {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
if (!hasDate && row.status === "Scheduled") {
|
||||
return { status: "Pending" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
|
||||
return (
|
||||
patch.status !== undefined ||
|
||||
patch.scheduledOn !== undefined ||
|
||||
patch.scheduleWeekOnly !== undefined ||
|
||||
patch.targetWeek !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeAutoSchedulePatch(
|
||||
row: WorkOrderTableRow | undefined,
|
||||
patch: WorkOrderTablePatch,
|
||||
): WorkOrderTablePatch {
|
||||
if (!row || !patchTouchesScheduleOrStatus(patch)) {
|
||||
return patch;
|
||||
}
|
||||
const merged = {
|
||||
status: patch.status ?? row.status,
|
||||
scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
|
||||
};
|
||||
return { ...patch, ...maybeAutoSchedulePatch(merged) };
|
||||
}
|
||||
|
||||
/** Apply schedule fields and the AAP auto-status for that next schedule. */
|
||||
export function withAutoScheduleStatus<T extends ScheduleDateInput & { status?: string }>(
|
||||
current: T,
|
||||
schedulePatch: Partial<T> & ScheduleDateInput,
|
||||
): Partial<T> {
|
||||
const next = {
|
||||
status: schedulePatch.status ?? current.status ?? "",
|
||||
scheduledOn:
|
||||
schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
schedulePatch.scheduleWeekOnly !== undefined
|
||||
? schedulePatch.scheduleWeekOnly
|
||||
: current.scheduleWeekOnly,
|
||||
};
|
||||
return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
|
||||
}
|
||||
|
||||
/** AAP create: apply maybeAutoSchedule to the draft before POST. */
|
||||
export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
|
||||
const autoStatus = maybeAutoSchedulePatch(row).status;
|
||||
if (autoStatus === "Scheduled" || autoStatus === "Pending") {
|
||||
return autoStatus;
|
||||
}
|
||||
return (row.status || "Incomplete") as WizardWOStatus;
|
||||
}
|
||||
|
|
@ -92,4 +92,39 @@ describe("buildInlineDraftCreateInput", () => {
|
|||
expect(input.assigneeId).toBe("");
|
||||
expect(payload).not.toHaveProperty("assignTo");
|
||||
});
|
||||
|
||||
it("promotes Pending to Scheduled when create has a concrete date", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25006",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
status: "Pending" as const,
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
expect(input.status).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("demotes Scheduled to Pending when create has no concrete date", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25005",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
status: "Scheduled" as const,
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "",
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
expect(input.status).toBe("Pending");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,8 +31,47 @@ describe("StatusCell closability gate", () => {
|
|||
const completed = screen.getByRole("button", { name: /^completed$/i });
|
||||
|
||||
expect(completed.hasAttribute("disabled")).toBe(true);
|
||||
expect(completed.getAttribute("title") ?? "").toContain("Missing:");
|
||||
expect(completed.getAttribute("title") ?? "").toContain("Fill in");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
|
||||
});
|
||||
|
||||
it("does not offer Canceled", () => {
|
||||
render(<StatusCell wo={incompleteRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("disables Scheduled when the row has no concrete date", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
render(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Pending", scheduledOn: "" }}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /pending/i }));
|
||||
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
|
||||
expect(scheduled).toBeDisabled();
|
||||
fireEvent.click(scheduled);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("limits creation options to Incomplete, Pending, and Scheduled", () => {
|
||||
render(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Incomplete" }}
|
||||
statusOptions={["Incomplete", "Pending", "Scheduled"]}
|
||||
onChangeStatus={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,18 +22,20 @@ const pastDueRow = {
|
|||
dueDate: "2026-08-01",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("StatusCell Past Due copy (SH-185)", () => {
|
||||
describe("StatusCell Past Due copy", () => {
|
||||
it("directs the dispatcher to update Schedule On, not Due Date", () => {
|
||||
render(<StatusCell wo={pastDueRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /past due/i });
|
||||
expect(trigger.getAttribute("title") ?? "").toContain("Schedule On");
|
||||
expect(trigger.getAttribute("title") ?? "").toContain("Reschedule to unlock status updates");
|
||||
expect(trigger.getAttribute("title") ?? "").not.toMatch(/due date/i);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByText(/update schedule on before the status can be updated/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /update schedule on/i })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/a new date must be scheduled before the status can be updated/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /reschedule/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/due date/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -227,7 +227,10 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
expect(completed).toHaveAttribute(
|
||||
"title",
|
||||
"Fill in Vendor before completing this work order.",
|
||||
);
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
},
|
||||
|
|
@ -254,7 +257,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
|
||||
});
|
||||
|
||||
it("allows Completed after a vendor patch clears leftover refused status", () => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,28 +19,25 @@ describe("WizardStepVendorTime Appt Time", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("WizardStepVendorTime closability gate", () => {
|
||||
it("disables Completed when closability requirements are missing", () => {
|
||||
const onUpdate = vi.fn();
|
||||
describe("WizardStepVendorTime creation statuses (AAP)", () => {
|
||||
it("offers only Incomplete, Pending, and Scheduled", () => {
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={onUpdate}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
const completed = screen.getByRole("button", { name: /^completed$/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
|
||||
|
||||
fireEvent.click(completed);
|
||||
expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
expect(screen.getByRole("button", { name: /^pending$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^scheduled$/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^completed$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^en route$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows selecting a non-Completed status", () => {
|
||||
it("disables Scheduled without a concrete date", () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
|
|
@ -51,10 +48,54 @@ describe("WizardStepVendorTime closability gate", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
|
||||
expect(scheduled).toBeDisabled();
|
||||
expect(scheduled).toHaveAttribute(
|
||||
"title",
|
||||
"Set a Schedule On date before marking this work order as Scheduled.",
|
||||
);
|
||||
fireEvent.click(scheduled);
|
||||
expect(onUpdate).not.toHaveBeenCalledWith("status", "Scheduled");
|
||||
});
|
||||
|
||||
it("allows selecting a non-Completed status", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
|
||||
expect(menu).not.toBeNull();
|
||||
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
|
||||
expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
|
||||
expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
|
||||
});
|
||||
|
||||
it("promotes Pending to Scheduled when picked on a dated draft", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
|
||||
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
applySchedulePatch,
|
||||
useWorkOrderTableMutations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
|
|
@ -223,6 +224,43 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("maybeAutoSchedule on board patch", () => {
|
||||
it("optimistically promotes Incomplete to Scheduled when a date is set", async () => {
|
||||
const rowState = { ...BASE_ROW, status: "Incomplete" as const, scheduledOn: "" };
|
||||
patchBoardField.mockImplementation(async (_id: unknown, payload: { field: string }) => {
|
||||
if (payload.field === "lifecycleStatus") {
|
||||
return { ...rowState, status: "Scheduled", scheduledOn: "2026-07-15" };
|
||||
}
|
||||
return { ...rowState, scheduledOn: "2026-07-15" };
|
||||
});
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, applySchedulePatch("2026-07-15", false));
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ scheduledOn: "2026-07-15", status: "Scheduled" }),
|
||||
);
|
||||
await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
|
||||
expect(patchBoardField).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ field: "lifecycleStatus", value: "Scheduled" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySchedulePatch isAddOn optimistic clear", () => {
|
||||
it("clears isAddOn when schedule is cleared", () => {
|
||||
expect(applySchedulePatch("", false)).toMatchObject({
|
||||
|
|
@ -642,3 +680,39 @@ describe("useWorkOrderTableMutations apptTime clear", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations audit cache refresh", () => {
|
||||
it("invalidates work-order queries after a successful status transition", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
|
||||
const nextRow = { ...BASE_ROW, status: "En Route" as const, rowVersion: "wo-v2" };
|
||||
patchBoardField.mockReset();
|
||||
patchBoardField.mockResolvedValue(nextRow);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => BASE_ROW,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { status: "En Route" });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: queryKeys.workOrders.all }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,28 @@ describe("formatBoardAuditAction", () => {
|
|||
oldValue: "Scheduled",
|
||||
newValue: "Incomplete",
|
||||
}),
|
||||
).toBe('Changed status to "Incomplete"');
|
||||
).toBe('Changed status from "Scheduled" to "Incomplete"');
|
||||
});
|
||||
|
||||
it("includes the previous status in the audit sentence when present", () => {
|
||||
const sentence = formatBoardAuditAction({
|
||||
action: "StatusChanged",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "Pending",
|
||||
newValue: "En Route",
|
||||
});
|
||||
expect(sentence).toBe('Changed status from "Pending" to "En Route"');
|
||||
});
|
||||
|
||||
it("omits from when the previous status is empty", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "StatusChanged",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "",
|
||||
newValue: "Scheduled",
|
||||
}),
|
||||
).toBe('Changed status to "Scheduled"');
|
||||
});
|
||||
|
||||
it("formats WeekRolled / CarriedOver with increment", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
COMPLETED_DOC_BLOCK_MSG,
|
||||
COMPLETED_MEDIA_BLOCK_MSG,
|
||||
CREATION_INLINE_STATUSES,
|
||||
SCHEDULED_NEEDS_DATE_MSG,
|
||||
formatAapCompletedBlockTitle,
|
||||
getStatusOptionState,
|
||||
hasConcreteScheduleDate,
|
||||
maybeAutoSchedulePatch,
|
||||
mergeAutoSchedulePatch,
|
||||
resolveCreationStatus,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
describe("wo-lifecycle-transitions", () => {
|
||||
it("lists AAP board statuses without Canceled", () => {
|
||||
expect(BOARD_INLINE_STATUSES).toEqual([
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
"En Route",
|
||||
"On Site",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Rescheduled",
|
||||
"Pending Quote",
|
||||
]);
|
||||
expect(BOARD_INLINE_STATUSES).not.toContain("Canceled");
|
||||
});
|
||||
|
||||
it("lists AAP creation statuses only", () => {
|
||||
expect(CREATION_INLINE_STATUSES).toEqual(["Incomplete", "Pending", "Scheduled"]);
|
||||
});
|
||||
|
||||
it("treats week-only as no concrete schedule date", () => {
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", scheduleWeekOnly: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", schedMode: "week" })).toBe(false);
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "—" })).toBe(false);
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01" })).toBe(true);
|
||||
});
|
||||
|
||||
it("disables Scheduled without a concrete date", () => {
|
||||
expect(getStatusOptionState({ scheduledOn: "" }, "Scheduled", [])).toEqual({
|
||||
disabled: true,
|
||||
title: SCHEDULED_NEEDS_DATE_MSG,
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes Incomplete/Pending when a concrete date exists", () => {
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "Incomplete",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({ status: "Scheduled" });
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "Pending",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({ status: "Scheduled" });
|
||||
});
|
||||
|
||||
it("reverts Scheduled without a concrete date to Pending", () => {
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({ status: "Pending" });
|
||||
});
|
||||
|
||||
it("does not auto-change In Progress when a date exists", () => {
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "In Progress",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("merges auto-schedule onto an existing-row patch", () => {
|
||||
const row = {
|
||||
status: "Incomplete",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
} as WorkOrderTableRow;
|
||||
expect(
|
||||
mergeAutoSchedulePatch(row, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
|
||||
).toEqual({
|
||||
scheduledOn: "2026-06-02",
|
||||
scheduleWeekOnly: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
expect(mergeAutoSchedulePatch(row, { vendorId: "9" })).toEqual({ vendorId: "9" });
|
||||
expect(
|
||||
mergeAutoSchedulePatch({ ...row, scheduledOn: "2026-06-01" }, { vendorId: "9" }),
|
||||
).toEqual({ vendorId: "9" });
|
||||
});
|
||||
|
||||
it("promotes a pre-schedule wizard pick to Scheduled on a dated draft", () => {
|
||||
const draft = {
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
expect(withAutoScheduleStatus(draft, { status: "Pending" })).toEqual({ status: "Scheduled" });
|
||||
expect(withAutoScheduleStatus(draft, { status: "Incomplete" })).toEqual({
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("still derives Scheduled when a concrete date is set on a draft", () => {
|
||||
const draft = {
|
||||
status: "Pending",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
expect(
|
||||
withAutoScheduleStatus(draft, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
|
||||
).toEqual({
|
||||
scheduledOn: "2026-06-02",
|
||||
scheduleWeekOnly: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes pre-schedule picks on dated rows", () => {
|
||||
const row = {
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
} as WorkOrderTableRow;
|
||||
expect(mergeAutoSchedulePatch(row, { status: "Pending" })).toEqual({ status: "Scheduled" });
|
||||
expect(mergeAutoSchedulePatch(row, { status: "Incomplete" })).toEqual({
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("still demotes Scheduled when the schedule is cleared", () => {
|
||||
const row = {
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
} as WorkOrderTableRow;
|
||||
expect(mergeAutoSchedulePatch(row, { scheduledOn: "", scheduleWeekOnly: false })).toEqual({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
status: "Pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes a Pending create pick to Scheduled when the draft is dated", () => {
|
||||
expect(
|
||||
resolveCreationStatus({
|
||||
status: "Pending",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("demotes Scheduled without a concrete date at create", () => {
|
||||
expect(
|
||||
resolveCreationStatus({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toBe("Pending");
|
||||
});
|
||||
|
||||
it("uses AAP completed tooltip hierarchy", () => {
|
||||
expect(formatAapCompletedBlockTitle(["Dispatcher", "Company"])).toBe(
|
||||
"Fill in Assigned To, Vendor before completing this work order.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle(["Service"])).toBe(
|
||||
"Select a service before completing this work order.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle(["WO number"])).toBe(
|
||||
"Assign a real APM number (not SH) before completing this work order.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle([PENDING_UPLIFT_GAP])).toBe(
|
||||
"This work order has an uplift pending approval and cannot be completed.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle(["Completion document"])).toBe(COMPLETED_DOC_BLOCK_MSG);
|
||||
expect(formatAapCompletedBlockTitle(["At least 1 photo"])).toBe(COMPLETED_MEDIA_BLOCK_MSG);
|
||||
expect(formatAapCompletedBlockTitle(["Company", PENDING_UPLIFT_GAP])).toBe(
|
||||
"Fill in Vendor before completing this work order.",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue