mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 04:52:07 +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 { 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 { 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 { 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() {
|
export default function WorkOrderViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -41,13 +46,21 @@ export default function WorkOrderViewPage() {
|
||||||
return <DetailPageSkeleton variant="view" />;
|
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 (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<WorkOrderHeaderSection
|
<WorkOrderHeaderSection
|
||||||
wo={wo}
|
wo={wo}
|
||||||
statusAnchor={controller.statusAnchor}
|
statusAnchor={controller.statusAnchor}
|
||||||
onStatusAnchorChange={controller.setStatusAnchor}
|
onStatusAnchorChange={controller.setStatusAnchor}
|
||||||
onStatusChange={(status) => controller.statusMutation.mutate(status)}
|
onStatusChange={handleStatusChange}
|
||||||
onDispatch={() => controller.setDispatchOpen(true)}
|
onDispatch={() => controller.setDispatchOpen(true)}
|
||||||
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
onEdit={() => navigate(`/workorders/edit/${id}`)}
|
||||||
onBack={() => navigate("/workorders")}
|
onBack={() => navigate("/workorders")}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,29 @@ import { Menu, MenuItem } from "@mui/material";
|
||||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
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";
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
type WorkOrderStatusMenuProps = {
|
type WorkOrderStatusMenuProps = {
|
||||||
status: string;
|
status: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
closability: WoClosabilityInput;
|
||||||
onStatusChange: (status: string) => void;
|
onStatusChange: (status: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderStatusMenu({
|
export function WorkOrderStatusMenu({
|
||||||
status,
|
status,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
closability,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
}: WorkOrderStatusMenuProps) {
|
}: WorkOrderStatusMenuProps) {
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
const closabilityGaps = getClosabilityGaps(closability);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -29,21 +38,31 @@ export function WorkOrderStatusMenu({
|
||||||
<WorkOrderStatusBadge status={status} />
|
<WorkOrderStatusBadge status={status} />
|
||||||
</MenuAnchorButton>
|
</MenuAnchorButton>
|
||||||
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||||
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
|
{WORK_ORDER_STATUS_OPTIONS.map((option) => {
|
||||||
<MenuItem
|
const isCompletionStatus = isCompletionStatusLabel(option);
|
||||||
key={option}
|
const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
|
||||||
selected={option === status}
|
return (
|
||||||
disabled={disabled}
|
<MenuItem
|
||||||
onClick={() => {
|
key={option}
|
||||||
close();
|
selected={option === status}
|
||||||
if (option !== status) {
|
disabled={disabled || isCompletedBlocked}
|
||||||
onStatusChange(option);
|
title={
|
||||||
|
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
|
||||||
}
|
}
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
if (isCompletedBlocked) {
|
||||||
{option}
|
return;
|
||||||
</MenuItem>
|
}
|
||||||
))}
|
close();
|
||||||
|
if (option !== status) {
|
||||||
|
onStatusChange(option);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import {
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
||||||
|
|
@ -36,6 +40,7 @@ export function StatusCell({
|
||||||
onPastDueAttempt,
|
onPastDueAttempt,
|
||||||
}: StatusCellProps) {
|
}: StatusCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const closabilityGaps = getClosabilityGaps(wo);
|
||||||
|
|
||||||
if (wo.isPastDue) {
|
if (wo.isPastDue) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -135,24 +140,39 @@ export function StatusCell({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||||
>
|
>
|
||||||
{ALL_WIZARD_STATUSES.map((status) => (
|
{ALL_WIZARD_STATUSES.map((status) => {
|
||||||
<button
|
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||||
key={status}
|
const missingTitle = isCompletedBlocked
|
||||||
type="button"
|
? formatClosabilityMissingTitle(closabilityGaps)
|
||||||
onClick={() => {
|
: undefined;
|
||||||
onChangeStatus(status);
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
return (
|
||||||
}}
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
key={status}
|
||||||
>
|
type="button"
|
||||||
<WoTableStatusLabel status={status} />
|
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 && (
|
{wo.status === status && (
|
||||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
||||||
site: draft.site,
|
site: draft.site,
|
||||||
location: draft.location,
|
location: draft.location,
|
||||||
dispatcherId: draft.dispatcherId,
|
dispatcherId: draft.dispatcherId,
|
||||||
|
dispatcherName: "",
|
||||||
scheduledOn: draft.scheduledOn,
|
scheduledOn: draft.scheduledOn,
|
||||||
|
completedDate: "",
|
||||||
dueDate: draft.dueDate,
|
dueDate: draft.dueDate,
|
||||||
pm: draft.pm,
|
pm: draft.pm,
|
||||||
serviceNotes: draft.serviceNotes,
|
serviceNotes: draft.serviceNotes,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import {
|
||||||
WORK_ORDER_STATUS_OPTIONS,
|
WORK_ORDER_STATUS_OPTIONS,
|
||||||
type WorkOrderStatus,
|
type WorkOrderStatus,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import {
|
||||||
|
detailToClosabilityInput,
|
||||||
|
formatClosabilityMissingTitle,
|
||||||
|
getClosabilityGaps,
|
||||||
|
isCompletionStatusLabel,
|
||||||
|
} from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
const STATUS_COLORS: Record<
|
const STATUS_COLORS: Record<
|
||||||
string,
|
string,
|
||||||
|
|
@ -36,6 +42,8 @@ export function WorkOrderHeaderSection({
|
||||||
onEdit,
|
onEdit,
|
||||||
onBack,
|
onBack,
|
||||||
}: WorkOrderHeaderSectionProps) {
|
}: WorkOrderHeaderSectionProps) {
|
||||||
|
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
|
|
@ -73,20 +81,31 @@ export function WorkOrderHeaderSection({
|
||||||
open={Boolean(statusAnchor)}
|
open={Boolean(statusAnchor)}
|
||||||
onClose={() => onStatusAnchorChange(null)}
|
onClose={() => onStatusAnchorChange(null)}
|
||||||
>
|
>
|
||||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
|
||||||
<MenuItem
|
const isCompletionStatus = isCompletionStatusLabel(status);
|
||||||
key={status}
|
const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
|
||||||
selected={status === wo.status}
|
return (
|
||||||
onClick={() => {
|
<MenuItem
|
||||||
onStatusAnchorChange(null);
|
key={status}
|
||||||
if (status !== wo.status) {
|
selected={status === wo.status}
|
||||||
onStatusChange(status);
|
disabled={isCompletedBlocked}
|
||||||
|
title={
|
||||||
|
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
|
||||||
}
|
}
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
if (isCompletedBlocked) {
|
||||||
{status}
|
return;
|
||||||
</MenuItem>
|
}
|
||||||
))}
|
onStatusAnchorChange(null);
|
||||||
|
if (status !== wo.status) {
|
||||||
|
onStatusChange(status);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,17 @@ export function WizardFieldSelectOptions({
|
||||||
<button
|
<button
|
||||||
key={o.key}
|
key={o.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPick(o.key)}
|
disabled={o.disabled}
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
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>}
|
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
|
||||||
<span className="min-w-0 flex-1 text-left">
|
<span className="min-w-0 flex-1 text-left">
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,6 @@ export interface WizardMenuOption {
|
||||||
description?: string;
|
description?: string;
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ import {
|
||||||
formatVendorDistance,
|
formatVendorDistance,
|
||||||
formatVendorTechnicianLabel,
|
formatVendorTechnicianLabel,
|
||||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
} 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 { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
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({
|
export function WizardStepVendorTime({
|
||||||
draft,
|
draft,
|
||||||
vendors,
|
vendors,
|
||||||
|
|
@ -120,6 +138,7 @@ export function WizardStepVendorTime({
|
||||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||||
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
||||||
|
const closabilityGaps = getClosabilityGaps(wizardDraftToClosabilityInput(draft));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|
@ -242,12 +261,11 @@ export function WizardStepVendorTime({
|
||||||
placeholder="Select status…"
|
placeholder="Select status…"
|
||||||
search={false}
|
search={false}
|
||||||
icon={<ColorDot color={statusDotColor} />}
|
icon={<ColorDot color={statusDotColor} />}
|
||||||
options={ALL_WIZARD_STATUSES.map((s) => ({
|
options={buildWizardStatusOptions(closabilityGaps)}
|
||||||
key: s,
|
onPick={(s) => {
|
||||||
label: s,
|
if (s === "Completed" && closabilityGaps.length > 0) return;
|
||||||
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
|
onUpdate("status", s as WizardWOStatus);
|
||||||
}))}
|
}}
|
||||||
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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,
|
scheduleWeekOnly: true,
|
||||||
targetWeek: "2026-07-13",
|
targetWeek: "2026-07-13",
|
||||||
scheduledOn: "",
|
scheduledOn: "",
|
||||||
|
completedDate: "",
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ field: "scheduleWeekOnly", value: "true" },
|
{ field: "scheduleWeekOnly", value: "true" },
|
||||||
|
|
@ -94,6 +95,7 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
expandTablePatchToBoardFieldOps({
|
expandTablePatchToBoardFieldOps({
|
||||||
scheduleWeekOnly: false,
|
scheduleWeekOnly: false,
|
||||||
scheduledOn: "2026-07-15",
|
scheduledOn: "2026-07-15",
|
||||||
|
completedDate: "",
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,14 @@ describe("buildDayGroups", () => {
|
||||||
const later = makeRow({
|
const later = makeRow({
|
||||||
id: "2",
|
id: "2",
|
||||||
scheduledOn: "2026-06-24",
|
scheduledOn: "2026-06-24",
|
||||||
|
completedDate: "",
|
||||||
dayGroup: "06/24",
|
dayGroup: "06/24",
|
||||||
dayLabel: "WED · 06/24",
|
dayLabel: "WED · 06/24",
|
||||||
});
|
});
|
||||||
const earlier = makeRow({
|
const earlier = makeRow({
|
||||||
id: "1",
|
id: "1",
|
||||||
scheduledOn: "2026-06-23",
|
scheduledOn: "2026-06-23",
|
||||||
|
completedDate: "",
|
||||||
dayGroup: "06/23",
|
dayGroup: "06/23",
|
||||||
dayLabel: "TUE · 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