mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 12:03:11 +00:00
fix(work-orders): compare completion labels via string helper
[recover] remove malicious eslint payload (was 19dfa0f9)
This commit is contained in:
parent
6fe1cd0f27
commit
c850be2aeb
15 changed files with 631 additions and 53 deletions
|
|
@ -14,7 +14,12 @@ import { WorkOrderDispatchesAccordion } from "@/app/(protected)/workorders/_comp
|
|||
import { WorkOrderActivityPanel } from "@/app/(protected)/workorders/_components/view/work-order-activity-panel";
|
||||
import { WorkOrderVendorEditorDialog } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog";
|
||||
import { useWorkOrderViewController } from "@/app/(protected)/workorders/_hooks/use-work-order-view-controller";
|
||||
|
||||
import {
|
||||
detailToClosabilityInput,
|
||||
getClosabilityGaps,
|
||||
isCompletionStatusLabel,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order";
|
||||
export default function WorkOrderViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -41,13 +46,21 @@ export default function WorkOrderViewPage() {
|
|||
return <DetailPageSkeleton variant="view" />;
|
||||
}
|
||||
|
||||
const handleStatusChange = (status: WorkOrderStatus) => {
|
||||
const isCompletionStatus = isCompletionStatusLabel(status);
|
||||
if (isCompletionStatus && getClosabilityGaps(detailToClosabilityInput(wo)).length > 0) {
|
||||
return;
|
||||
}
|
||||
controller.statusMutation.mutate(status);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<WorkOrderHeaderSection
|
||||
wo={wo}
|
||||
statusAnchor={controller.statusAnchor}
|
||||
onStatusAnchorChange={controller.setStatusAnchor}
|
||||
onStatusChange={(status) => controller.statusMutation.mutate(status)}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDispatch={() => controller.setDispatchOpen(true)}
|
||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
||||
onBack={() => navigate("/workorders")}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,29 @@ import { Menu, MenuItem } from "@mui/material";
|
|||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
isCompletionStatusLabel,
|
||||
type WoClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||
|
||||
type WorkOrderStatusMenuProps = {
|
||||
status: string;
|
||||
disabled?: boolean;
|
||||
closability: WoClosabilityInput;
|
||||
onStatusChange: (status: string) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderStatusMenu({
|
||||
status,
|
||||
disabled = false,
|
||||
closability,
|
||||
onStatusChange,
|
||||
}: WorkOrderStatusMenuProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
const closabilityGaps = getClosabilityGaps(closability);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -29,21 +38,31 @@ export function WorkOrderStatusMenu({
|
|||
<WorkOrderStatusBadge status={status} />
|
||||
</MenuAnchorButton>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
|
||||
<MenuItem
|
||||
key={option}
|
||||
selected={option === status}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
close();
|
||||
if (option !== status) {
|
||||
onStatusChange(option);
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((option) => {
|
||||
const isCompletionStatus = isCompletionStatusLabel(option);
|
||||
const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
selected={option === status}
|
||||
disabled={disabled || isCompletedBlocked}
|
||||
title={
|
||||
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
if (option !== status) {
|
||||
onStatusChange(option);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import {
|
|||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
|
|
@ -36,6 +40,7 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = getClosabilityGaps(wo);
|
||||
|
||||
if (wo.isPastDue) {
|
||||
return (
|
||||
|
|
@ -135,24 +140,39 @@ 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) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChangeStatus(status);
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
return;
|
||||
}
|
||||
onChangeStatus(status);
|
||||
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]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
||||
{wo.status === status && (
|
||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{wo.status === status && (
|
||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
|||
site: draft.site,
|
||||
location: draft.location,
|
||||
dispatcherId: draft.dispatcherId,
|
||||
dispatcherName: "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
completedDate: "",
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
serviceNotes: draft.serviceNotes,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import {
|
|||
WORK_ORDER_STATUS_OPTIONS,
|
||||
type WorkOrderStatus,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
detailToClosabilityInput,
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
isCompletionStatusLabel,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
|
|
@ -36,6 +42,8 @@ export function WorkOrderHeaderSection({
|
|||
onEdit,
|
||||
onBack,
|
||||
}: WorkOrderHeaderSectionProps) {
|
||||
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs>
|
||||
|
|
@ -73,20 +81,31 @@ export function WorkOrderHeaderSection({
|
|||
open={Boolean(statusAnchor)}
|
||||
onClose={() => onStatusAnchorChange(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
onStatusAnchorChange(null);
|
||||
if (status !== wo.status) {
|
||||
onStatusChange(status);
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
|
||||
const isCompletionStatus = isCompletionStatusLabel(status);
|
||||
const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
|
||||
return (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
disabled={isCompletedBlocked}
|
||||
title={
|
||||
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
return;
|
||||
}
|
||||
onStatusAnchorChange(null);
|
||||
if (status !== wo.status) {
|
||||
onStatusChange(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,17 @@ export function WizardFieldSelectOptions({
|
|||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
onClick={() => onPick(o.key)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
disabled={o.disabled}
|
||||
title={o.title}
|
||||
onClick={() => {
|
||||
if (o.disabled) return;
|
||||
onPick(o.key);
|
||||
}}
|
||||
className={
|
||||
o.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 transition-colors hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@ export interface WizardMenuOption {
|
|||
description?: string;
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import {
|
|||
formatVendorDistance,
|
||||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
|
|
@ -110,6 +115,19 @@ function WizardVendorDetail({
|
|||
);
|
||||
}
|
||||
|
||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
||||
return ALL_WIZARD_STATUSES.map((s) => {
|
||||
const isCompletedBlocked = s === "Completed" && closabilityGaps.length > 0;
|
||||
return {
|
||||
key: s,
|
||||
label: s,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
|
||||
disabled: isCompletedBlocked,
|
||||
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function WizardStepVendorTime({
|
||||
draft,
|
||||
vendors,
|
||||
|
|
@ -120,6 +138,7 @@ export function WizardStepVendorTime({
|
|||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
||||
const closabilityGaps = getClosabilityGaps(wizardDraftToClosabilityInput(draft));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -242,12 +261,11 @@ export function WizardStepVendorTime({
|
|||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={ALL_WIZARD_STATUSES.map((s) => ({
|
||||
key: s,
|
||||
label: s,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
|
||||
}))}
|
||||
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
|
||||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
onPick={(s) => {
|
||||
if (s === "Completed" && closabilityGaps.length > 0) return;
|
||||
onUpdate("status", s as WizardWOStatus);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
129
src/domain/work-orders/utils/wo-closability.ts
Normal file
129
src/domain/work-orders/utils/wo-closability.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
/** Fields required to evaluate whether a WO may be marked Completed. */
|
||||
export type WoClosabilityInput = {
|
||||
dispatcherId: string;
|
||||
dispatcherName: string;
|
||||
/** Company / vendor assignment — required for Completed (SH-183). */
|
||||
company: string;
|
||||
/** Technician is optional for closability; modeled separately from company. */
|
||||
tech: string;
|
||||
completedDate: string;
|
||||
woNumber: string;
|
||||
pm: string;
|
||||
docStatus: CompletionDocStatus;
|
||||
mediaCount: number;
|
||||
type: WOType | "";
|
||||
};
|
||||
|
||||
/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */
|
||||
export function isRealWoNumber(woNumber: string): boolean {
|
||||
const trimmed = woNumber.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return !/^SH/i.test(trimmed);
|
||||
}
|
||||
|
||||
/** Legacy detail menus use "Done"; board/wizard lifecycle uses "Completed". */
|
||||
export function isCompletionStatusLabel(status: string): boolean {
|
||||
return status === "Done" || status === "Completed";
|
||||
}
|
||||
|
||||
function hasDispatcher(row: Pick<WoClosabilityInput, "dispatcherId" | "dispatcherName">): boolean {
|
||||
return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim());
|
||||
}
|
||||
|
||||
function hasCompany(company: string): boolean {
|
||||
return Boolean(company.trim());
|
||||
}
|
||||
|
||||
function hasService(pm: string): boolean {
|
||||
return Boolean(pm.trim());
|
||||
}
|
||||
|
||||
function hasCompletionDocument(docStatus: CompletionDocStatus): boolean {
|
||||
return docStatus === "Yes" || docStatus === "NN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns human-readable labels for requirements still missing before Completed is allowed.
|
||||
* Empty array means the WO may be marked Completed.
|
||||
*/
|
||||
export function getClosabilityGaps(row: WoClosabilityInput): string[] {
|
||||
const gaps: string[] = [];
|
||||
|
||||
if (!hasDispatcher(row)) {
|
||||
gaps.push("Dispatcher");
|
||||
}
|
||||
if (!hasCompany(row.company)) {
|
||||
gaps.push("Company");
|
||||
}
|
||||
if (!row.completedDate.trim()) {
|
||||
gaps.push("Completed Date");
|
||||
}
|
||||
if (!isRealWoNumber(row.woNumber)) {
|
||||
gaps.push("WO number");
|
||||
}
|
||||
if (!hasService(row.pm)) {
|
||||
gaps.push("Service");
|
||||
}
|
||||
|
||||
if (row.type === "PM" || row.type === "Overdue") {
|
||||
if (!hasCompletionDocument(row.docStatus)) {
|
||||
gaps.push("Completion document");
|
||||
}
|
||||
}
|
||||
|
||||
if (row.type === "Emergency" || row.type === "Reactive") {
|
||||
if (row.mediaCount < 1) {
|
||||
gaps.push("At least 1 photo");
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
export function formatClosabilityMissingTitle(gaps: string[]): string {
|
||||
return `Missing: ${gaps.join(", ")}`;
|
||||
}
|
||||
|
||||
/** Map create-wizard draft onto the shared closability contract. */
|
||||
export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput {
|
||||
return {
|
||||
dispatcherId: draft.assigneeId,
|
||||
dispatcherName: draft.assigneeName,
|
||||
company: draft.vendorName,
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: draft.woNumber,
|
||||
pm: draft.pm,
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: draft.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map standalone detail DTO onto closability. Fields the detail route does not
|
||||
* expose (service, docStatus, media, type) stay empty so Completed fails closed.
|
||||
*/
|
||||
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
|
||||
const vendorName = wo.dispatches.find((dispatch) =>
|
||||
Boolean(dispatch.vendorName.trim()),
|
||||
)?.vendorName;
|
||||
return {
|
||||
dispatcherId: "",
|
||||
dispatcherName: wo.assignedTo,
|
||||
company: vendorName ?? "",
|
||||
tech: "",
|
||||
completedDate: wo.completedDate,
|
||||
woNumber: wo.workOrderNumber || wo.internalWONumber,
|
||||
pm: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: "",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const incompleteRow = {
|
||||
id: "wo-1",
|
||||
status: "Scheduled",
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "",
|
||||
pm: "",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: "PM",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("StatusCell closability gate", () => {
|
||||
it("does not submit Completed when required work-order data is missing", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
|
||||
render(
|
||||
<StatusCell wo={incompleteRow} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
const completed = screen.getByRole("button", { name: /^completed$/i });
|
||||
|
||||
expect(completed.hasAttribute("disabled")).toBe(true);
|
||||
expect(completed.getAttribute("title") ?? "").toContain("Missing:");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
describe("WizardStepVendorTime closability gate", () => {
|
||||
it("disables Completed when closability requirements are missing", () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
|
||||
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");
|
||||
});
|
||||
|
||||
it("allows selecting a non-Completed status", () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={onUpdate}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkOrderHeaderSection } from "@/app/(protected)/workorders/_components/view/work-order-header-section";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
const incompleteDetail = {
|
||||
id: 1,
|
||||
internalWONumber: "SH-1",
|
||||
workOrderNumber: "",
|
||||
title: "Test WO",
|
||||
location: "",
|
||||
locationId: "",
|
||||
locationZip: "",
|
||||
assignedTo: "",
|
||||
dueDate: "",
|
||||
createdDate: "",
|
||||
createdDateFull: "",
|
||||
description: "",
|
||||
priority: "",
|
||||
status: "Open",
|
||||
po: "",
|
||||
tt: "",
|
||||
problem: "",
|
||||
trade: "",
|
||||
subTrade: "",
|
||||
vendorNTE: "",
|
||||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
source: "",
|
||||
comments: [],
|
||||
auditLog: [],
|
||||
dispatches: [],
|
||||
} as WorkOrderDetail;
|
||||
|
||||
describe("WorkOrderHeaderSection closability gate", () => {
|
||||
it("does not submit Done when closability gaps remain", () => {
|
||||
const onStatusChange = vi.fn();
|
||||
const onStatusAnchorChange = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<WorkOrderHeaderSection
|
||||
wo={incompleteDetail}
|
||||
statusAnchor={null}
|
||||
onStatusAnchorChange={onStatusAnchorChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onDispatch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Open"));
|
||||
const anchor = onStatusAnchorChange.mock.calls[0]?.[0] as HTMLElement;
|
||||
expect(anchor).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<WorkOrderHeaderSection
|
||||
wo={incompleteDetail}
|
||||
statusAnchor={anchor}
|
||||
onStatusAnchorChange={onStatusAnchorChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onDispatch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const done = screen.getByRole("menuitem", { name: /^Done$/i });
|
||||
expect(done.getAttribute("aria-disabled")).toBe("true");
|
||||
fireEvent.click(done);
|
||||
expect(onStatusChange).not.toHaveBeenCalledWith("Done");
|
||||
});
|
||||
});
|
||||
|
|
@ -81,6 +81,7 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-07-13",
|
||||
scheduledOn: "",
|
||||
completedDate: "",
|
||||
}),
|
||||
).toEqual([
|
||||
{ field: "scheduleWeekOnly", value: "true" },
|
||||
|
|
@ -94,6 +95,7 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
expandTablePatchToBoardFieldOps({
|
||||
scheduleWeekOnly: false,
|
||||
scheduledOn: "2026-07-15",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
}),
|
||||
).toEqual([
|
||||
|
|
|
|||
|
|
@ -83,12 +83,14 @@ describe("buildDayGroups", () => {
|
|||
const later = makeRow({
|
||||
id: "2",
|
||||
scheduledOn: "2026-06-24",
|
||||
completedDate: "",
|
||||
dayGroup: "06/24",
|
||||
dayLabel: "WED · 06/24",
|
||||
});
|
||||
const earlier = makeRow({
|
||||
id: "1",
|
||||
scheduledOn: "2026-06-23",
|
||||
completedDate: "",
|
||||
dayGroup: "06/23",
|
||||
dayLabel: "TUE · 06/23",
|
||||
});
|
||||
|
|
|
|||
184
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
184
src/test/domain/work-orders/utils/wo-closability.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
detailToClosabilityInput,
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
isRealWoNumber,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
|
||||
return {
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alice",
|
||||
company: "ACME",
|
||||
tech: "Bob",
|
||||
completedDate: "2026-07-15",
|
||||
woNumber: "20260623001",
|
||||
pm: "HVAC",
|
||||
docStatus: "Yes",
|
||||
mediaCount: 2,
|
||||
type: "PM",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isRealWoNumber", () => {
|
||||
it("rejects empty and SH-prefixed placeholders", () => {
|
||||
expect(isRealWoNumber("")).toBe(false);
|
||||
expect(isRealWoNumber(" ")).toBe(false);
|
||||
expect(isRealWoNumber("SH-123")).toBe(false);
|
||||
expect(isRealWoNumber("sh999")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts non-SH numbers", () => {
|
||||
expect(isRealWoNumber("20260623001")).toBe(true);
|
||||
expect(isRealWoNumber(" 42 ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClosabilityGaps", () => {
|
||||
it("returns empty when all requirements are met", () => {
|
||||
expect(getClosabilityGaps(makeInput())).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists universal gaps", () => {
|
||||
expect(
|
||||
getClosabilityGaps(
|
||||
makeInput({
|
||||
dispatcherId: "",
|
||||
dispatcherName: "",
|
||||
company: "",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "SH-1",
|
||||
pm: "",
|
||||
}),
|
||||
),
|
||||
).toEqual(["Dispatcher", "Company", "Completed Date", "WO number", "Service"]);
|
||||
});
|
||||
|
||||
it("treats dispatcherName alone as sufficient", () => {
|
||||
expect(getClosabilityGaps(makeInput({ dispatcherId: "", dispatcherName: "Alice" }))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("allows company without technician (SH-183)", () => {
|
||||
const gaps = getClosabilityGaps(makeInput({ company: "ACME", tech: "" }));
|
||||
expect(gaps).toEqual([]);
|
||||
expect(gaps).not.toContain("Technician");
|
||||
expect(gaps).not.toContain("Company");
|
||||
});
|
||||
|
||||
it("blocks missing company even when technician is set", () => {
|
||||
expect(getClosabilityGaps(makeInput({ company: "", tech: "Bob" }))).toEqual(["Company"]);
|
||||
expect(getClosabilityGaps(makeInput({ company: " ", tech: "Bob" }))).toEqual(["Company"]);
|
||||
});
|
||||
|
||||
it("requires completion document for PM and Overdue", () => {
|
||||
expect(getClosabilityGaps(makeInput({ type: "PM", docStatus: "No" }))).toEqual([
|
||||
"Completion document",
|
||||
]);
|
||||
expect(getClosabilityGaps(makeInput({ type: "Overdue", docStatus: "" }))).toEqual([
|
||||
"Completion document",
|
||||
]);
|
||||
expect(getClosabilityGaps(makeInput({ type: "PM", docStatus: "NN" }))).toEqual([]);
|
||||
expect(getClosabilityGaps(makeInput({ type: "Overdue", docStatus: "Yes" }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires at least one photo for Emergency and Reactive", () => {
|
||||
expect(
|
||||
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })),
|
||||
).toEqual(["At least 1 photo"]);
|
||||
expect(getClosabilityGaps(makeInput({ type: "Reactive", mediaCount: 0 }))).toEqual([
|
||||
"At least 1 photo",
|
||||
]);
|
||||
expect(
|
||||
getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 1, docStatus: "No" })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks missing service on every type", () => {
|
||||
expect(getClosabilityGaps(makeInput({ type: "Reactive", pm: "", mediaCount: 1 }))).toEqual([
|
||||
"Service",
|
||||
]);
|
||||
expect(getClosabilityGaps(makeInput({ type: "PM", pm: " ", docStatus: "Yes" }))).toEqual([
|
||||
"Service",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatClosabilityMissingTitle", () => {
|
||||
it("joins gaps for tooltip copy", () => {
|
||||
expect(formatClosabilityMissingTitle(["Dispatcher", "Service"])).toBe(
|
||||
"Missing: Dispatcher, Service",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wizardDraftToClosabilityInput", () => {
|
||||
it("maps vendorName to company and leaves tech empty", () => {
|
||||
const input = wizardDraftToClosabilityInput({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
assigneeId: "u1",
|
||||
assigneeName: "Alice",
|
||||
vendorName: "ACME",
|
||||
woNumber: "20260623001",
|
||||
pm: "HVAC",
|
||||
type: "PM",
|
||||
});
|
||||
|
||||
expect(input).toEqual({
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Alice",
|
||||
company: "ACME",
|
||||
tech: "",
|
||||
completedDate: "",
|
||||
woNumber: "20260623001",
|
||||
pm: "HVAC",
|
||||
docStatus: "",
|
||||
mediaCount: 0,
|
||||
type: "PM",
|
||||
});
|
||||
expect(getClosabilityGaps(input)).toContain("Completed Date");
|
||||
expect(getClosabilityGaps(input)).toContain("Completion document");
|
||||
expect(getClosabilityGaps(input)).not.toContain("Company");
|
||||
expect(getClosabilityGaps(input)).not.toContain("Technician");
|
||||
});
|
||||
|
||||
it("blocks Completed for a sparse create draft", () => {
|
||||
const gaps = getClosabilityGaps(wizardDraftToClosabilityInput(EMPTY_WIZARD_DRAFT));
|
||||
expect(gaps.length).toBeGreaterThan(0);
|
||||
expect(gaps).toEqual(
|
||||
expect.arrayContaining(["Dispatcher", "Company", "Completed Date", "WO number", "Service"]),
|
||||
);
|
||||
expect(gaps).not.toContain("Technician");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detailToClosabilityInput", () => {
|
||||
it("maps vendorName to company and fails closed on missing service/media fields", () => {
|
||||
const detail = {
|
||||
assignedTo: "Alice",
|
||||
completedDate: "2026-07-15",
|
||||
workOrderNumber: "20260623001",
|
||||
internalWONumber: "SH-1",
|
||||
dispatches: [{ vendorName: "ACME Tech" }],
|
||||
} as WorkOrderDetail;
|
||||
|
||||
const input = detailToClosabilityInput(detail);
|
||||
expect(input.dispatcherName).toBe("Alice");
|
||||
expect(input.company).toBe("ACME Tech");
|
||||
expect(input.tech).toBe("");
|
||||
expect(input.completedDate).toBe("2026-07-15");
|
||||
expect(input.woNumber).toBe("20260623001");
|
||||
expect(input.pm).toBe("");
|
||||
expect(getClosabilityGaps(input)).toContain("Service");
|
||||
expect(getClosabilityGaps(input)).not.toContain("Company");
|
||||
expect(getClosabilityGaps(input)).not.toContain("Technician");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue