mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 00:53:14 +00:00
feat(work-orders): align inline lifecycle transitions with AAP
Use the prototype status list and gates on the board and wizard. Auto-derive Scheduled or Pending from a concrete schedule date.
This commit is contained in:
parent
c70587941c
commit
9cc9bc4dfb
13 changed files with 504 additions and 72 deletions
|
|
@ -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);
|
||||
|
|
@ -164,20 +169,17 @@ export function StatusCell({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] 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,7 +191,7 @@ export function StatusCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
option.disabled
|
||||
? "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]"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 @@ 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 } 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";
|
||||
|
|
@ -215,6 +216,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}
|
||||
|
|
|
|||
|
|
@ -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,14 @@ 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,
|
||||
} 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 +173,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 +192,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 +271,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);
|
||||
}}
|
||||
/>
|
||||
</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 = {
|
||||
|
|
@ -125,9 +131,12 @@ export function useWorkOrderTableMutations(
|
|||
const patchMutation = useMutation({
|
||||
meta: { suppressErrorToast: true },
|
||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||
enqueuePatch(id, () => {
|
||||
const next = mergeAutoSchedulePatch(getRow?.(id), patch);
|
||||
return runWorkOrderTablePatch(id, next, getRow);
|
||||
}),
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
onPatch(id, mergeAutoSchedulePatch(getRow?.(id), patch));
|
||||
},
|
||||
onSuccess: (result, { id }) => {
|
||||
if (result.kind === "board") {
|
||||
|
|
@ -189,19 +198,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,
|
||||
|
|
|
|||
169
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
169
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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: Pick<WorkOrderTableRow, "status" | "scheduledOn" | "scheduleWeekOnly">,
|
||||
): 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) };
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -19,25 +19,44 @@ describe("WizardStepVendorTime Appt Time", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("WizardStepVendorTime closability gate", () => {
|
||||
it("disables Completed when closability requirements are missing", () => {
|
||||
describe("WizardStepVendorTime creation statuses (AAP)", () => {
|
||||
it("offers only Incomplete, Pending, and Scheduled", () => {
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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("disables Scheduled without a concrete date", () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={onUpdate}
|
||||
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 }));
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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,39 @@ 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());
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySchedulePatch isAddOn optimistic clear", () => {
|
||||
it("clears isAddOn when schedule is cleared", () => {
|
||||
expect(applySchedulePatch("", false)).toMatchObject({
|
||||
|
|
@ -642,3 +676,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 }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ describe("formatBoardAuditAction", () => {
|
|||
).toBe('Changed status to "Incomplete"');
|
||||
});
|
||||
|
||||
it("does not include the previous status in the audit sentence", () => {
|
||||
const sentence = formatBoardAuditAction({
|
||||
action: "StatusChanged",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "Pending",
|
||||
newValue: "En Route",
|
||||
});
|
||||
expect(sentence).toBe('Changed status to "En Route"');
|
||||
expect(sentence).not.toMatch(/from/i);
|
||||
expect(sentence).not.toContain("Pending");
|
||||
});
|
||||
|
||||
it("formats WeekRolled / CarriedOver with increment", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
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,
|
||||
} 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" });
|
||||
});
|
||||
|
||||
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