mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
Merge branch 'dev' into feature/wo-multi-poc-contacts
This commit is contained in:
commit
afdf667364
23 changed files with 791 additions and 50 deletions
|
|
@ -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)}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@ export interface WizardMenuOption {
|
|||
description?: string;
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("")}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
100
src/domain/work-orders/utils/read-detail-closability-fields.ts
Normal file
100
src/domain/work-orders/utils/read-detail-closability-fields.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue