diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index d3882148..8c63b1b0 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -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 ( view.statusMutation.mutate(status)} + onStatusChange={handleStatusChange} onOpenDispatch={() => view.setDispatchOpen(true)} /> diff --git a/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx b/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx index 15945501..e01cd994 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-status-menu.tsx @@ -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({ - {WORK_ORDER_STATUS_OPTIONS.map((option) => ( - { - close(); - if (option !== status) { - onStatusChange(option); + {WORK_ORDER_STATUS_OPTIONS.map((option) => { + const isCompletionStatus = isCompletionStatusLabel(option); + const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0; + return ( + - {option} - - ))} + onClick={() => { + if (isCompletedBlocked) { + return; + } + close(); + if (option !== status) { + onStatusChange(option); + } + }} + > + {option} + + ); + })} ); diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts index 1c4952ad..283b4ebd 100644 --- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts +++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index 9fd5723c..9e975fbd 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -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; @@ -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} diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx index 101abf72..8a9af6d6 100644 --- a/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx +++ b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx @@ -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) => ( - { - onStatusAnchorChange(null); - if (status !== wo.status) { - onStatusChange(status); - } - }} - > - {status} - - ))} + {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 ( + { + if (isCompletedBlocked) { + return; + } + onStatusAnchorChange(null); + if (status !== wo.status) { + onStatusChange(status); + } + }} + > + {status} + + ); + })} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx index 8eba4e0e..4dd7381d 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx @@ -49,32 +49,45 @@ export function WizardFieldSelectOptions({ No matches ) : ( - options.map((o) => ( + options.map((option) => ( diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts index 17958148..83c53ae0 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts @@ -6,4 +6,6 @@ export interface WizardMenuOption { description?: string; left?: ReactNode; right?: ReactNode; + disabled?: boolean; + title?: string; } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index 7dbcb199..e28272e3 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -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: , + 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 (
@@ -228,6 +253,21 @@ export function WizardStepVendorTime({ )}
+ +
+ Status + } + options={buildWizardStatusOptions(closabilityGaps)} + onPick={(status) => { + if (status === "Completed" && closabilityGaps.length > 0) return; + onUpdate("status", status as WizardWOStatus); + }} + /> +
); } diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts index d2dc9183..2965876b 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts @@ -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, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 30bab7af..fc44166d 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -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("")} diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 259c5344..303b03fa 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -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) diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts index 8504fa09..da2e8063 100644 --- a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts +++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts @@ -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: [], diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts index 0eb2b780..8ca7bd01 100644 --- a/src/domain/work-orders/types/work-order-detail.ts +++ b/src/domain/work-orders/types/work-order-detail.ts @@ -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. */ diff --git a/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts new file mode 100644 index 00000000..9d315378 --- /dev/null +++ b/src/domain/work-orders/utils/enrich-detail-closability-from-board.ts @@ -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, + }; +} diff --git a/src/domain/work-orders/utils/read-detail-closability-fields.ts b/src/domain/work-orders/utils/read-detail-closability-fields.ts new file mode 100644 index 00000000..99d2d686 --- /dev/null +++ b/src/domain/work-orders/utils/read-detail-closability-fields.ts @@ -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 { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...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, ...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): 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): 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): 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; +} diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index be377091..1290ecd3 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -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, ): 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, + }; +} diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx new file mode 100644 index 00000000..1ca88e1b --- /dev/null +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -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( + , + ); + + 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"); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx new file mode 100644 index 00000000..12ad3346 --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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"); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx new file mode 100644 index 00000000..a61df935 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-header-closability.test.tsx @@ -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 ( + + + + ); +} + +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"); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 36286ee5..f26d13fe 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -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: "" }, ]); }); diff --git a/src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts b/src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts new file mode 100644 index 00000000..2f981501 --- /dev/null +++ b/src/test/domain/work-orders/utils/enrich-detail-closability-from-board.test.ts @@ -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"); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 6f6eb105..a8b09c3a 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -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", }); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 8af2724e..c900ccec 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -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 { @@ -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(""); + }, + ); +});