Merge branch 'dev' into feature/wo-multi-poc-contacts

This commit is contained in:
Arthur Bassi 2026-08-19 10:29:18 -03:00 • committed by GitHub
commit afdf667364
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 791 additions and 50 deletions

View file

@ -11,6 +11,12 @@ import { WorkOrderViewAccordions } from "./_components/view/work-order-view-acco
import { WorkOrderViewDetails } from "./_components/view/work-order-view-details";
import { WorkOrderViewHeader } from "./_components/view/work-order-view-header";
import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page";
import {
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();
@ -32,14 +38,28 @@ export default function WorkOrderViewPage() {
);
}
const wo = view.wo;
const handleStatusChange = (status: string) => {
const isCompletionStatus = isCompletionStatusLabel(status);
if (
isCompletionStatus &&
(!view.isClosabilityReady || getClosabilityGaps(detailToClosabilityInput(wo)).length > 0)
) {
return;
}
view.statusMutation.mutate(status as WorkOrderStatus);
};
return (
<Box className="flex flex-col gap-4 p-4">
<WorkOrderViewHeader
wo={view.wo}
wo={wo}
workOrderId={workOrderId}
isClosabilityReady={view.isClosabilityReady}
statusAnchor={view.statusAnchor}
onStatusAnchorChange={view.setStatusAnchor}
onStatusChange={(status) => view.statusMutation.mutate(status)}
onStatusChange={handleStatusChange}
onOpenDispatch={() => view.setDispatchOpen(true)}
/>

View file

@ -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>
</>
);

View file

@ -15,6 +15,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
site: draft.site,
location: draft.location,
dispatcherId: draft.dispatcherId,
dispatcherName: "",
scheduledOn: draft.scheduledOn,
completedDate: "",
dueDate: draft.dueDate,

View file

@ -6,6 +6,16 @@ import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
function openCompletionDoc(
row: WorkOrderTableRow,
setDocRow: (row: WorkOrderTableRow | null) => void,
) {
if (!hasCompletionDocService(row.pm)) return;
setDocRow(row);
}
type WorkOrdersListPagePanelsProps = {
page: ReturnType<typeof useWorkOrdersListPage>;
@ -46,7 +56,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
pmTypes={pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}

View file

@ -2,6 +2,12 @@ import { Link as RouterLink, useNavigate } from "react-router";
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import {
detailToClosabilityInput,
formatClosabilityMissingTitle,
getClosabilityGaps,
isCompletionStatusLabel,
} from "@/domain/work-orders/utils/wo-closability";
const STATUS_COLORS: Record<
string,
@ -14,9 +20,13 @@ const STATUS_COLORS: Record<
Cancelled: "default",
};
const CLOSABILITY_UNAVAILABLE_TITLE = "Unable to verify completion requirements";
type WorkOrderViewHeaderProps = {
wo: WorkOrderDetail;
workOrderId: string;
/** False while board detail is pending/errored — fail closed for Done. */
isClosabilityReady: boolean;
statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: string) => void;
@ -26,12 +36,14 @@ type WorkOrderViewHeaderProps = {
export function WorkOrderViewHeader({
wo,
workOrderId,
isClosabilityReady,
statusAnchor,
onStatusAnchorChange,
onStatusChange,
onOpenDispatch,
}: WorkOrderViewHeaderProps) {
const navigate = useNavigate();
const closabilityGaps = getClosabilityGaps(detailToClosabilityInput(wo));
return (
<>
@ -61,20 +73,33 @@ export function WorkOrderViewHeader({
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);
}
}}
>
{status}
</MenuItem>
))}
{WORK_ORDER_STATUS_OPTIONS.map((status) => {
const isCompletionStatus = isCompletionStatusLabel(status);
const isCompletedBlocked =
isCompletionStatus && (!isClosabilityReady || closabilityGaps.length > 0);
const blockedTitle = !isClosabilityReady
? CLOSABILITY_UNAVAILABLE_TITLE
: formatClosabilityMissingTitle(closabilityGaps);
return (
<MenuItem
key={status}
selected={status === wo.status}
disabled={isCompletedBlocked}
title={isCompletedBlocked ? blockedTitle : undefined}
onClick={() => {
if (isCompletedBlocked) {
return;
}
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
);
})}
</Menu>
</Stack>

View file

@ -49,32 +49,45 @@ export function WizardFieldSelectOptions({
No matches
</Text>
) : (
options.map((o) => (
options.map((option) => (
<button
key={o.key}
key={option.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={option.disabled}
title={option.title}
onClick={() => {
if (option.disabled) return;
onPick(option.key);
}}
className={
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 transition-colors hover:bg-[#F9FAFB]"
}
>
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
{option.left != null && (
<span className="flex shrink-0 items-center">{option.left}</span>
)}
<span className="min-w-0 flex-1 text-left">
<span
className="block truncate text-[12px]"
style={{ color: "var(--color-text-primary)" }}
>
{o.label}
{option.label}
</span>
{Boolean(o.description) && (
{Boolean(option.description) && (
<span
className="mt-0.5 block truncate text-[11px]"
style={{ color: "var(--color-text-muted)" }}
>
{o.description}
{option.description}
</span>
)}
</span>
{o.right != null && <span className="flex shrink-0 items-center">{o.right}</span>}
{value === o.key && (
{option.right != null && (
<span className="flex shrink-0 items-center">{option.right}</span>
)}
{value === option.key && (
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
)}
</button>

View file

@ -6,4 +6,6 @@ export interface WizardMenuOption {
description?: string;
left?: ReactNode;
right?: ReactNode;
disabled?: boolean;
title?: string;
}

View file

@ -2,7 +2,10 @@ 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,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
@ -10,12 +13,18 @@ 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,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
@ -106,6 +115,19 @@ function WizardVendorDetail({
);
}
function buildWizardStatusOptions(closabilityGaps: string[]) {
return ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
return {
key: status,
label: status,
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
disabled: isCompletedBlocked,
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
};
});
}
export function WizardStepVendorTime({
draft,
vendors,
@ -113,7 +135,10 @@ export function WizardStepVendorTime({
onPatch,
}: WizardStepVendorTimeProps) {
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;
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
const closabilityGaps = getClosabilityGaps(wizardDraftToClosabilityInput(draft));
return (
<div className="space-y-5">
@ -228,6 +253,21 @@ export function WizardStepVendorTime({
</Text>
)}
</div>
<div>
<WizLabel>Status</WizLabel>
<WizardFieldSelect
value={statusValue}
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={buildWizardStatusOptions(closabilityGaps)}
onPick={(status) => {
if (status === "Completed" && closabilityGaps.length > 0) return;
onUpdate("status", status as WizardWOStatus);
}}
/>
</div>
</div>
);
}

View file

@ -1,7 +1,10 @@
import { useMemo, useState } from "react";
import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types";
import { isVendorEditorValid } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useWorkOrderBoardDetail,
useWorkOrderDetail,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
@ -11,9 +14,14 @@ import {
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board";
export function useWorkOrderViewPage(workOrderId: string) {
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: detail, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: boardDetail, isSuccess: isBoardDetailSuccess } =
useWorkOrderBoardDetail(workOrderId);
/** Fail closed until board detail succeeds — legacy GetById lacks type/doc/media. */
const isClosabilityReady = isBoardDetailSuccess && boardDetail != null;
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
@ -28,6 +36,11 @@ export function useWorkOrderViewPage(workOrderId: string) {
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
const wo = useMemo(
() => (detail ? enrichDetailClosabilityFromBoard(detail, boardDetail) : undefined),
[detail, boardDetail],
);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
@ -119,6 +132,7 @@ export function useWorkOrderViewPage(workOrderId: string) {
return {
wo,
isLoading,
isClosabilityReady,
error,
refetch,
users,

View file

@ -7,8 +7,18 @@ import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/l
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
function openCompletionDoc(
row: WorkOrderTableRow,
setDocRow: (row: WorkOrderTableRow | null) => void,
) {
if (!hasCompletionDocService(row.pm)) return;
setDocRow(row);
}
export default function WorkOrdersListPage() {
const { user } = useAuthContext();
const page = useWorkOrdersListPage();
@ -80,7 +90,7 @@ export default function WorkOrdersListPage() {
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow}
onClearSearch={() => filters.handleSearchChange("")}

View file

@ -1,5 +1,9 @@
import { readDocStatusFromRecord } from "@/domain/work-orders/mappers/doc-status-mapper";
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
import {
readDetailDocStatus,
readDetailMediaCount,
readDetailWoType,
} from "@/domain/work-orders/utils/read-detail-closability-fields";
import type {
EmployeeOption,
LocationOption,
@ -314,9 +318,10 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")),
// DocStatus is a board field — persist via PATCH …/board, not EditWorkorder.
docStatus: readDocStatusFromRecord(wo),
source: readString(wo, "source", "Source"),
type: readDetailWoType(wo),
docStatus: readDetailDocStatus(wo),
mediaCount: readDetailMediaCount(wo),
...(contactIds !== undefined ? { contactIds } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
comments: Array.isArray(wo.comments)

View file

@ -153,8 +153,10 @@ export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrder
vendorNTE: values.vendorNTE,
scheduledDate: values.scheduledDate,
completedDate: values.completedDate,
docStatus: values.docStatus ?? "",
source: values.source,
type: "",
docStatus: values.docStatus ?? "",
mediaCount: 0,
comments: [],
auditLog: [],
dispatches: [],

View file

@ -1,6 +1,7 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch";
import type { CompletionDocStatus } from "./work-order-table-row";
import type { WOType } from "./work-order-wizard";
export interface WorkOrderComment {
id: string | number;
@ -52,9 +53,13 @@ export interface WorkOrderDetail {
vendorNTE: string;
scheduledDate: string;
completedDate: string;
/** Completion-document status (API DocStatus); persisted via board PATCH. */
docStatus: CompletionDocStatus;
source: string;
/** Authoritative WO type (not integration `source`). Empty when unknown. */
type: WOType | "";
/** Completion document status (API DocStatus); used for PM/Overdue closability and board PATCH. */
docStatus: CompletionDocStatus;
/** Photo/media count for Emergency/Reactive closability gates. */
mediaCount: number;
/** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
contactIds?: number[];
/** Present when GetById returns categories; used to re-send on edit so backend does not wipe. */

View file

@ -0,0 +1,22 @@
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
/**
* Overlay authoritative closability fields from board detail onto a legacy GetById detail.
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources.
*/
export function enrichDetailClosabilityFromBoard(
detail: WorkOrderDetail,
board: WorkOrderBoardDetail | undefined,
): WorkOrderDetail {
if (!board) {
return detail;
}
return {
...detail,
type: board.info.type,
docStatus: board.completion.docStatus,
mediaCount: board.media.length,
};
}

View file

@ -0,0 +1,100 @@
import {
DOC_STATUS_API,
WORK_ORDER_TYPE_API_TO_FE,
} from "@/domain/work-orders/types/work-order-board";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
// SH-126/SH-184: Add-On is a backend-authoritative indicator (`isAddOn`), not a
// selectable business type, so it is no longer part of the WOType union.
const DETAIL_WO_TYPES = new Set(["PM", "Reactive", "Emergency", "Overdue"]);
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return String(value);
}
}
return "";
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "number") {
return value;
}
if (typeof value === "string" && value !== "") {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
}
return null;
}
/** Authoritative WO type only — never falls back to integration `source`. */
export function readDetailWoType(record: Record<string, unknown>): WOType | "" {
const numeric =
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
}
const raw = readString(
record,
"workOrderType",
"WorkOrderType",
"type",
"Type",
"woType",
"WoType",
);
if (DETAIL_WO_TYPES.has(raw)) {
return raw as WOType;
}
return "";
}
export function readDetailDocStatus(record: Record<string, unknown>): CompletionDocStatus {
const numeric = readNumber(record, "docStatus", "DocStatus");
if (numeric === DOC_STATUS_API.Yes) return "Yes";
if (numeric === DOC_STATUS_API.No) return "No";
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
const nestedCompletion = asRecord(record.completion ?? record.Completion);
const nestedNumeric = readNumber(nestedCompletion, "docStatus", "DocStatus");
if (nestedNumeric === DOC_STATUS_API.Yes) return "Yes";
if (nestedNumeric === DOC_STATUS_API.No) return "No";
if (nestedNumeric === DOC_STATUS_API.NotNeeded) return "NN";
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
if (raw === "Yes" || raw === "No" || raw === "NN") {
return raw;
}
if (raw === "NotNeeded") {
return "NN";
}
return "";
}
export function readDetailMediaCount(record: Record<string, unknown>): number {
const explicit = readNumber(record, "mediaCount", "MediaCount");
if (explicit != null) {
return explicit;
}
const media = record.media ?? record.Media;
if (Array.isArray(media)) {
return media.length;
}
return 0;
}

View file

@ -1,3 +1,4 @@
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type {
CompletionDocStatus,
WorkOrderUpliftSummary,
@ -38,6 +39,11 @@ export function isRealWoNumber(woNumber: string): boolean {
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";
}
export function hasPendingUplift(
row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
): boolean {
@ -146,3 +152,26 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
type: draft.type,
};
}
/**
* Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` /
* `mediaCount` (enriched from board detail on the view route). Never treats `source`
* as WorkOrderType. `problem` is the same service field the board maps into `pm`.
*/
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: wo.problem,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
};
}

View file

@ -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");
});
});

View file

@ -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");
});
});

View file

@ -0,0 +1,143 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { WorkOrderViewHeader } from "@/app/(protected)/workorders/_components/view/work-order-view-header";
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: "",
type: "",
docStatus: "",
mediaCount: 0,
comments: [],
auditLog: [],
dispatches: [],
} as WorkOrderDetail;
/** Base gates satisfied, type still empty (unenriched) — race window without board detail. */
const unenrichedButOtherwiseClosable = {
...incompleteDetail,
assignedTo: "Alice",
workOrderNumber: "20260623001",
problem: "HVAC",
completedDate: "2026-07-15",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "Acme HVAC" }],
} as WorkOrderDetail;
function renderHeader(props: {
wo: WorkOrderDetail;
isClosabilityReady: boolean;
statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: string) => void;
}) {
return (
<MemoryRouter>
<WorkOrderViewHeader
wo={props.wo}
workOrderId="1"
isClosabilityReady={props.isClosabilityReady}
statusAnchor={props.statusAnchor}
onStatusAnchorChange={props.onStatusAnchorChange}
onStatusChange={props.onStatusChange}
onOpenDispatch={vi.fn()}
/>
</MemoryRouter>
);
}
describe("WorkOrderViewHeader closability gate", () => {
it("does not submit Done when closability gaps remain", () => {
const onStatusChange = vi.fn();
const onStatusAnchorChange = vi.fn();
const { rerender } = render(
renderHeader({
wo: incompleteDetail,
isClosabilityReady: true,
statusAnchor: null,
onStatusAnchorChange,
onStatusChange,
}),
);
fireEvent.click(screen.getByText("Open"));
const anchor = onStatusAnchorChange.mock.calls[0]?.[0] as HTMLElement;
expect(anchor).toBeTruthy();
rerender(
renderHeader({
wo: incompleteDetail,
isClosabilityReady: true,
statusAnchor: anchor,
onStatusAnchorChange,
onStatusChange,
}),
);
const done = screen.getByRole("menuitem", { name: /^Done$/i });
expect(done.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(done);
expect(onStatusChange).not.toHaveBeenCalledWith("Done");
});
it("does not submit Done while board closability is not ready", () => {
const onStatusChange = vi.fn();
const onStatusAnchorChange = vi.fn();
const { rerender } = render(
renderHeader({
wo: unenrichedButOtherwiseClosable,
isClosabilityReady: false,
statusAnchor: null,
onStatusAnchorChange,
onStatusChange,
}),
);
fireEvent.click(screen.getByText("Open"));
const anchor = onStatusAnchorChange.mock.calls[0]?.[0] as HTMLElement;
expect(anchor).toBeTruthy();
rerender(
renderHeader({
wo: unenrichedButOtherwiseClosable,
isClosabilityReady: false,
statusAnchor: anchor,
onStatusAnchorChange,
onStatusChange,
}),
);
const done = screen.getByRole("menuitem", { name: /^Done$/i });
expect(done.getAttribute("aria-disabled")).toBe("true");
expect(done).toHaveAttribute("title", "Unable to verify completion requirements");
fireEvent.click(done);
expect(onStatusChange).not.toHaveBeenCalledWith("Done");
});
});

View file

@ -87,11 +87,13 @@ describe("expandTablePatchToBoardFieldOps", () => {
scheduleWeekOnly: true,
targetWeek: "2026-07-13",
scheduledOn: "",
completedDate: "",
}),
).toEqual([
{ field: "scheduleWeekOnly", value: "true" },
{ field: "targetWeek", value: "2026-07-13" },
{ field: "scheduledDate", value: "" },
{ field: "completedDate", value: "" },
]);
});
@ -100,12 +102,14 @@ describe("expandTablePatchToBoardFieldOps", () => {
expandTablePatchToBoardFieldOps({
scheduleWeekOnly: false,
scheduledOn: "2026-07-15",
completedDate: "",
targetWeek: "",
}),
).toEqual([
{ field: "scheduleWeekOnly", value: "false" },
{ field: "scheduledDate", value: "2026-07-15" },
{ field: "targetWeek", value: "" },
{ field: "completedDate", value: "" },
]);
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { enrichDetailClosabilityFromBoard } from "@/domain/work-orders/utils/enrich-detail-closability-from-board";
const baseDetail = {
id: 1,
internalWONumber: "SH-1",
workOrderNumber: "20260623001",
title: "Test",
location: "",
locationId: "",
locationZip: "",
assignedTo: "Alice",
dueDate: "",
createdDate: "",
createdDateFull: "",
description: "",
priority: "",
status: "Open",
po: "",
tt: "",
problem: "HVAC",
trade: "",
subTrade: "",
vendorNTE: "",
scheduledDate: "",
completedDate: "2026-07-15",
source: "PM",
type: "" as const,
docStatus: "" as const,
mediaCount: 0,
comments: [],
auditLog: [],
dispatches: [],
} satisfies WorkOrderDetail;
describe("enrichDetailClosabilityFromBoard", () => {
it("returns detail unchanged when board is undefined", () => {
expect(enrichDetailClosabilityFromBoard(baseDetail, undefined)).toBe(baseDetail);
});
it("overlays authoritative type, docStatus, and mediaCount from board", () => {
const board = {
info: { type: "PM" },
completion: { docStatus: "Yes" },
media: [{ id: "1" }, { id: "2" }],
} as WorkOrderBoardDetail;
const enriched = enrichDetailClosabilityFromBoard(baseDetail, board);
expect(enriched.type).toBe("PM");
expect(enriched.docStatus).toBe("Yes");
expect(enriched.mediaCount).toBe(2);
expect(enriched.source).toBe("PM");
expect(enriched.problem).toBe("HVAC");
});
});

View file

@ -269,12 +269,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",
});

View file

@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import { readDetailWoType } from "@/domain/work-orders/utils/read-detail-closability-fields";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import {
PENDING_UPLIFT_GAP,
UPLIFT_PENDING_COMPLETED_TITLE,
canGenerateCompDoc,
detailToClosabilityInput,
formatClosabilityMissingTitle,
getClosabilityGaps,
hasPendingUplift,
isCompletionStatusLabel,
isRealWoNumber,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
@ -36,6 +42,14 @@ describe("isRealWoNumber", () => {
});
});
describe("isCompletionStatusLabel", () => {
it("treats Done and Completed as completion labels", () => {
expect(isCompletionStatusLabel("Done")).toBe(true);
expect(isCompletionStatusLabel("Completed")).toBe(true);
expect(isCompletionStatusLabel("Scheduled")).toBe(false);
});
});
describe("hasPendingUplift", () => {
it("is true when hasPendingUplift flag is set", () => {
expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true);
@ -157,3 +171,126 @@ describe("canGenerateCompDoc", () => {
});
});
});
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");
});
});
describe("detailToClosabilityInput", () => {
it("maps authoritative type/doc/media and problem so Done is not permanently blocked", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement-integration",
type: "",
docStatus: "",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech" }],
} as WorkOrderDetail;
const input = detailToClosabilityInput(detail);
expect(input.dispatcherName).toBe("Alice");
expect(input.company).toBe("ACME Tech");
expect(input.woNumber).toBe("20260623001");
expect(input.pm).toBe("HVAC PM");
expect(getClosabilityGaps(input)).toEqual([]);
});
it("does not treat integration source as WorkOrderType", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC",
source: "PM",
type: "",
docStatus: "No",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech" }],
} as WorkOrderDetail;
expect(detailToClosabilityInput(detail).type).toBe("");
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual([]);
});
it("blocks PM detail transition without completion document", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "HVAC PM",
source: "procurement",
type: "PM",
docStatus: "No",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech" }],
} as WorkOrderDetail;
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["Completion document"]);
});
it("blocks Emergency detail transition without photos", () => {
const detail = {
assignedTo: "Alice",
completedDate: "2026-07-15",
workOrderNumber: "20260623001",
internalWONumber: "SH-1",
problem: "Leak",
source: "portal",
type: "Emergency",
docStatus: "No",
mediaCount: 0,
dispatches: [{ vendorName: "ACME Tech" }],
} as WorkOrderDetail;
expect(getClosabilityGaps(detailToClosabilityInput(detail))).toEqual(["At least 1 photo"]);
});
});
describe("readDetailWoType", () => {
it.each(["PM", "Reactive", "Emergency", "Overdue"])(
"passes through the business type %s",
(type) => {
expect(readDetailWoType({ workOrderType: type })).toBe(type);
},
);
it.each(["Add-On", "AddOn"])(
"normalizes the legacy Add-On payload %s to empty (SH-126/SH-184)",
(legacy) => {
expect(readDetailWoType({ workOrderType: legacy })).toBe("");
},
);
});