@@ -65,6 +66,9 @@ export function WoTableInlineIdentityCells({
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
+ ...(p.additionalContacts !== undefined
+ ? { additionalContacts: p.additionalContacts }
+ : {}),
})
}
/>
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index a022f6fa..f50d464d 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -160,7 +160,6 @@ export function WoTableInlineRowCells({
}}
/>
-
@@ -170,7 +169,6 @@ export function WoTableInlineRowCells({
onSave={(date) => onPatch({ dueDate: date })}
/>
-
-
-
-
@@ -62,6 +63,9 @@ export function WoTableRowCells({
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
+ ...(p.additionalContacts !== undefined
+ ? { additionalContacts: p.additionalContacts }
+ : {}),
})
}
/>
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
index d783a944..4706ce02 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
@@ -9,6 +9,7 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
+import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
@@ -29,7 +30,7 @@ type WoTableProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
weekMonday: string;
- onWeekChange: (mondayIso: string) => void;
+ onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
@@ -40,6 +41,34 @@ type WoTableProps = {
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
};
+type WoTableEmptyShellProps = {
+ message: string;
+ actionLabel: string;
+ onAction?: () => void;
+};
+
+function WoTableLoading({
+ weekMonday,
+ selectedDayIso,
+}: {
+ weekMonday: string;
+ selectedDayIso?: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShellProps) {
+ return (
+
+
+
+ );
+}
+
export function WoTable({
tableData,
users,
@@ -47,7 +76,7 @@ export function WoTable({
vendors,
pmTypes,
weekMonday,
- onWeekChange,
+ onNavigateToDay,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
@@ -70,7 +99,8 @@ export function WoTable({
const handlers = useWoTableRowHandlers({
tableData,
weekMonday,
- onWeekChange,
+ onNavigateToDay,
+ onOpenAdvancedFilters,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
@@ -125,9 +155,7 @@ export function WoTable({
if (tableData.isLoading) {
return (
-
-
-
+
);
}
@@ -135,25 +163,26 @@ export function WoTable({
if (emptyAdv) {
return (
-
-
-
+
);
}
- if (!tableData.advActive && tableData.noSearchResults) {
+ const showSearchEmpty =
+ !tableData.advActive &&
+ tableData.noSearchResults &&
+ isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
+
+ if (showSearchEmpty) {
return (
-
-
-
+
);
}
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/loading/wo-table-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx
index 85cee325..d5c3e8fa 100644
--- a/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx
+++ b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx
@@ -1,7 +1,7 @@
import { Skeleton } from "@mui/material";
import { LoadingRegion } from "@/components/ui/loading-region";
import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows";
-import { buildWeekSkeleton } from "@/domain/work-orders/utils/table-day-utils";
+import { formatDayTabLabel, weekdaysMonFri } from "@/domain/work-orders/utils/week-day-nav-utils";
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
import {
WO_TABLE_COLS,
@@ -11,13 +11,26 @@ import {
type WoTableSkeletonProps = {
weekMonday: string;
+ selectedDayIso?: string;
rowsPerDay?: number;
};
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
-export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) {
- const days = buildWeekSkeleton(weekMonday);
+function skeletonDayIso(weekMonday: string, selectedDayIso?: string): string {
+ const days = weekdaysMonFri(weekMonday);
+ if (selectedDayIso && days.includes(selectedDayIso)) {
+ return selectedDayIso;
+ }
+ return weekMonday;
+}
+
+export function WoTableSkeleton({
+ weekMonday,
+ selectedDayIso,
+ rowsPerDay = 2,
+}: WoTableSkeletonProps) {
+ const dayIso = skeletonDayIso(weekMonday, selectedDayIso);
return (
@@ -30,9 +43,8 @@ export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonP
>
- {days.map((day) => (
-
- ))}
+
+
diff --git a/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
new file mode 100644
index 00000000..0449fadd
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx
@@ -0,0 +1,223 @@
+import { Plus, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import {
+ DLG_INPUT_CLS,
+ DLG_INPUT_STYLE,
+} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
+import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
+import { isIncompleteAdditionalContact } from "@/domain/work-orders/mappers/additional-contacts-mapper";
+import {
+ EMPTY_ADDITIONAL_CONTACT,
+ type WorkOrderAdditionalContact,
+} from "@/domain/work-orders/types/work-order-additional-contact";
+
+type PocContactFieldsProps = {
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
+ onPocNameChange: (value: string) => void;
+ onPocPhoneChange: (value: string) => void;
+ onPocNotesChange: (value: string) => void;
+ onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
+ helperText?: string;
+ showPrimaryErrors?: boolean;
+};
+
+function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
+ return isIncompleteAdditionalContact(contact) && !contact.name.trim();
+}
+
+function extraPhoneMissing(contact: WorkOrderAdditionalContact): boolean {
+ return isIncompleteAdditionalContact(contact) && !contact.phone.trim();
+}
+
+function updateContactAt(
+ contacts: WorkOrderAdditionalContact[],
+ index: number,
+ patch: Partial,
+): WorkOrderAdditionalContact[] {
+ return contacts.map((contact, contactIndex) =>
+ contactIndex === index ? { ...contact, ...patch } : contact,
+ );
+}
+
+function AdditionalPocCard({
+ contact,
+ index,
+ onChange,
+ onRemove,
+}: {
+ contact: WorkOrderAdditionalContact;
+ index: number;
+ onChange: (patch: Partial) => void;
+ onRemove: () => void;
+}) {
+ const nameMissing = extraNameMissing(contact);
+ const phoneMissing = extraPhoneMissing(contact);
+
+ return (
+
+
+
+ Point of contact {index + 2}
+
+
+
+
+
+
+
+ );
+}
+
+export function PocContactFields({
+ pocName,
+ pocPhone,
+ pocNotes,
+ additionalContacts,
+ onPocNameChange,
+ onPocPhoneChange,
+ onPocNotesChange,
+ onAdditionalContactsChange,
+ helperText,
+ showPrimaryErrors = false,
+}: PocContactFieldsProps) {
+ const nameMissing = showPrimaryErrors && !pocName.trim();
+ const phoneMissing = showPrimaryErrors && !pocPhone.trim();
+
+ const handleAddContact = () => {
+ onAdditionalContactsChange([...additionalContacts, { ...EMPTY_ADDITIONAL_CONTACT }]);
+ };
+
+ return (
+
+
Point of Contact
+ {Boolean(helperText) && (
+
+ {helperText}
+
+ )}
+
+
+ {additionalContacts.map((contact, index) => (
+
+ onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
+ }
+ onRemove={() =>
+ onAdditionalContactsChange(
+ additionalContacts.filter((_, contactIndex) => contactIndex !== index),
+ )
+ }
+ />
+ ))}
+
+
+
+ Add point of contact
+
+
+ );
+}
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/week-picker.tsx b/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
index 233939b0..faf128b1 100644
--- a/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
@@ -11,9 +11,10 @@ import {
type WeekPickerProps = {
selected: string;
onPick: (mondayIso: string) => void;
+ isWeekDisabled?: (mondayIso: string) => boolean;
};
-export function WeekPicker({ selected, onPick }: WeekPickerProps) {
+export function WeekPicker({ selected, onPick, isWeekDisabled }: WeekPickerProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
@@ -66,12 +67,14 @@ export function WeekPicker({ selected, onPick }: WeekPickerProps) {
{weeks.map((mon) => {
const isSel = Boolean(selected) && selected === mon;
const isThis = mon === thisMonday;
+ const isDisabled = Boolean(isWeekDisabled?.(mon));
return (
onPick(mon)}
- className="flex items-center transition-colors"
+ className="flex items-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
style={{
height: 36,
borderRadius: 6,
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) => (
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 && {o.left} }
+ {option.left != null && (
+ {option.left}
+ )}
- {o.label}
+ {option.label}
- {Boolean(o.description) && (
+ {Boolean(option.description) && (
- {o.description}
+ {option.description}
)}
- {o.right != null && {o.right} }
- {value === o.key && (
+ {option.right != null && (
+ {option.right}
+ )}
+ {value === option.key && (
)}
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-sidebar-summary-rows.ts b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
index 5295cc69..e49e267a 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
@@ -26,6 +26,18 @@ function pocLabel(draft: WorkOrderWizardDraft): string {
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName;
}
+function formatAdditionalPocSummary(draft: WorkOrderWizardDraft): string {
+ const count = draft.additionalContacts.length;
+ if (count === 0) {
+ return "—";
+ }
+ const names = draft.additionalContacts.map((contact) => contact.name.trim()).filter(Boolean);
+ if (names.length > 0) {
+ return names.join(", ");
+ }
+ return `${count} contact${count === 1 ? "" : "s"}`;
+}
+
/** Builds the label/value rows for the wizard sidebar's live summary card. */
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
return [
@@ -36,6 +48,7 @@ export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [str
["Assigned to", draft.assigneeName || "—"],
["Site", draft.siteCode || draft.locationName || "—"],
["POC", pocLabel(draft)],
+ ["Additional POCs", formatAdditionalPocSummary(draft)],
["Service", draft.pm || "—"],
["Technician", draft.vendorName || "—"],
["Company", draft.vendorName || "—"],
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
index cfd6f17f..61bf4927 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
@@ -1,10 +1,5 @@
-import { Text } from "@/components/ui/text";
+import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
-import {
- DLG_INPUT_CLS,
- DLG_INPUT_STYLE,
-} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
-import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepLocationPocFieldsProps = {
draft: WorkOrderWizardDraft;
@@ -20,44 +15,17 @@ export function WizardStepLocationPocFields({
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return (
-
-
Point of contact
-
- Auto-filled from records — edit if outdated
-
-
- onUpdate("pocName", e.target.value)}
- placeholder="POC name"
- className={DLG_INPUT_CLS}
- style={DLG_INPUT_STYLE}
- />
- onUpdate("pocPhone", e.target.value)}
- placeholder="POC phone"
- className={DLG_INPUT_CLS}
- style={DLG_INPUT_STYLE}
- />
-
- {Boolean(showErrors && pocIncomplete) && (
-
- POC name and phone are required.
-
- )}
-
+ onUpdate("pocName", value)}
+ onPocPhoneChange={(value) => onUpdate("pocPhone", value)}
+ onPocNotesChange={(value) => onUpdate("pocNotes", value)}
+ onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
+ helperText="Auto-filled from records — edit if outdated"
+ showPrimaryErrors={showErrors && pocIncomplete}
+ />
);
}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
index 8d06d966..583828d9 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
@@ -42,6 +42,7 @@ export function WizardStepLocationSiteSelect({
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
});
}}
/>
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/_hooks/use-work-orders-list-filters.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts
index 67ad6c11..581d1b0f 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-filters.ts
@@ -17,6 +17,12 @@ import {
toBoardQueryParams,
} from "@/domain/work-orders/utils/board-query-params";
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
+import {
+ firstWeekday,
+ initialSelectedDay,
+ scheduledDayTab,
+ weekdaysMonFri,
+} from "@/domain/work-orders/utils/week-day-nav-utils";
export function useWorkOrdersListFilters() {
const [search, setSearch] = useState("");
@@ -27,6 +33,9 @@ export function useWorkOrdersListFilters() {
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
+ const [selectedDayIso, setSelectedDayIsoState] = useState(() =>
+ initialSelectedDay(currentWeekMonday()),
+ );
const [dispatcherIds, setDispatcherIdsState] = useState>(() => new Set());
const [typeFilter, setTypeFilterState] = useState("All");
@@ -42,6 +51,7 @@ export function useWorkOrdersListFilters() {
);
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
+ const weekDays = useMemo(() => weekdaysMonFri(weekMonday), [weekMonday]);
const boardParams = useMemo(
() => toBoardQueryParams(barFilters, debouncedSearch),
@@ -55,6 +65,18 @@ export function useWorkOrdersListFilters() {
const setWeekMonday = (value: string) => {
setWeekMondayState(value);
+ setSelectedDayIsoState(firstWeekday(value));
+ setPage(0);
+ };
+
+ const setSelectedDayIso = (value: string) => {
+ setSelectedDayIsoState(value);
+ };
+
+ const navigateToDay = (dateIso: string) => {
+ const tab = scheduledDayTab(dateIso);
+ setWeekMondayState(tab.weekMonday);
+ setSelectedDayIsoState(tab.selectedDayIso);
setPage(0);
};
@@ -123,6 +145,8 @@ export function useWorkOrdersListFilters() {
sortBy,
sortDir,
weekMonday,
+ selectedDayIso,
+ weekDays,
dispatcherIds,
typeFilter,
barFilters,
@@ -139,6 +163,8 @@ export function useWorkOrdersListFilters() {
setPage,
setPageSize,
setWeekMonday,
+ setSelectedDayIso,
+ navigateToDay,
setDispatcherIds,
setTypeFilter,
setSortBy,
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts
index 752272de..f56409f9 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts
@@ -4,9 +4,13 @@ import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/us
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
+import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
+import {
+ buildSelectedDayGroup,
+ isDayScopedSearchEmpty,
+ isUnassignedQueueReady,
+} from "@/domain/work-orders/utils/week-day-nav-utils";
import {
- buildDayGroups,
composeBoardSections,
filterByBarAndSearch,
mergeWeeklyAndUnassignedQueue,
@@ -74,19 +78,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
});
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
- const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
- const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
+ const dayGroups = useMemo(
+ () =>
+ advActive
+ ? []
+ : [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
+ [advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
+ );
const searchActive = filters.search.trim() !== "";
- const visibleRows = advActive ? advResults : filtered;
- const noSearchResults = visibleRows.length === 0 && searchActive;
- const isEmptyWeek =
+ const displayGroups = dayGroups;
+ const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
+ const noSearchResults =
!advActive &&
- scheduled.length === 0 &&
- unassigned.length === 0 &&
- unscheduled.length === 0 &&
- !searchActive;
- const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
+ isDayScopedSearchEmpty({
+ searchActive,
+ unassignedCount: unassigned.length,
+ dayItemCount: dayVisibleCount,
+ unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
+ });
+ const isEmptyWeek =
+ !advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
@@ -138,9 +150,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
cancelDraft: local.cancelDraft,
addRow: local.addRow,
scopeTotalCount: data?.totalCount ?? 0,
- totalCount: advActive
- ? (data?.totalCount ?? advResults.length)
- : (data?.returnedCount ?? weeklyItems.length),
+ totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
search: filters.search,
};
}
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 30bab7af..8d5b1739 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();
@@ -35,6 +45,8 @@ export default function WorkOrdersListPage() {
openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow}
onClearSearch={() => filters.handleSearchChange("")}
diff --git a/src/components/ui/form-field.tsx b/src/components/ui/form-field.tsx
index f85215b0..560795ac 100644
--- a/src/components/ui/form-field.tsx
+++ b/src/components/ui/form-field.tsx
@@ -18,7 +18,7 @@ type FormFieldProps = FormControlProps & {
const appearanceClassName: Record = {
default: "gap-1",
- auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!tracking-normal",
+ auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!text-foreground [&_.MuiFormLabel-root]:!tracking-normal",
};
const appearanceLabelClassName: Record = {
diff --git a/src/domain/work-orders/mappers/additional-contacts-mapper.ts b/src/domain/work-orders/mappers/additional-contacts-mapper.ts
new file mode 100644
index 00000000..82ab5b17
--- /dev/null
+++ b/src/domain/work-orders/mappers/additional-contacts-mapper.ts
@@ -0,0 +1,102 @@
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+
+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 mapContactItem(raw: unknown): WorkOrderAdditionalContact | null {
+ const item = asRecord(raw);
+ const name = readString(item, "name", "Name");
+ const phone = readString(item, "phone", "Phone");
+ const notes = readString(item, "notes", "Notes");
+
+ if (!name.trim() && !phone.trim()) {
+ return null;
+ }
+
+ const contact: WorkOrderAdditionalContact = {
+ name,
+ phone,
+ };
+
+ if (notes.trim()) {
+ contact.notes = notes;
+ }
+
+ return contact;
+}
+
+/** Reads additional POCs from board/detail payloads; missing field yields []. */
+export function readAdditionalContacts(
+ record: Record,
+): WorkOrderAdditionalContact[] {
+ for (const key of ["additionalContacts", "AdditionalContacts"]) {
+ const value = record[key];
+ if (!Array.isArray(value)) {
+ continue;
+ }
+
+ return value
+ .map(mapContactItem)
+ .filter((contact): contact is WorkOrderAdditionalContact => contact !== null);
+ }
+
+ return [];
+}
+
+/** True when exactly one of name/phone is filled (not persistable). */
+export function isIncompleteAdditionalContact(contact: WorkOrderAdditionalContact): boolean {
+ const hasName = Boolean(contact.name.trim());
+ const hasPhone = Boolean(contact.phone.trim());
+ return hasName !== hasPhone;
+}
+
+/** Drops empty and incomplete rows; returns undefined when nothing to persist. */
+export function normalizeAdditionalContacts(
+ contacts: WorkOrderAdditionalContact[],
+): WorkOrderAdditionalContact[] | undefined {
+ const out: WorkOrderAdditionalContact[] = [];
+
+ for (const raw of contacts) {
+ const name = raw.name.trim();
+ const phone = raw.phone.trim();
+ const notes = raw.notes?.trim();
+
+ if (!name && !phone) {
+ continue;
+ }
+
+ if (!name || !phone) {
+ continue;
+ }
+
+ const contact: WorkOrderAdditionalContact = { name, phone };
+
+ if (notes) {
+ contact.notes = notes;
+ }
+
+ out.push(contact);
+ }
+
+ return out.length > 0 ? out : undefined;
+}
+
+export function serializeAdditionalContactsForPatch(
+ contacts: WorkOrderAdditionalContact[],
+): string {
+ return JSON.stringify(normalizeAdditionalContacts(contacts) ?? []);
+}
diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
index f6636932..e6041f83 100644
--- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
@@ -1,5 +1,6 @@
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
function trimOrUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
@@ -42,6 +43,11 @@ export function assignWizardServicePocFields(
payload.pocNotes = pocNotes;
}
+ const additionalContacts = normalizeAdditionalContacts(draft.additionalContacts);
+ if (additionalContacts) {
+ payload.additionalContacts = additionalContacts;
+ }
+
const techPhone = trimOrUndefined(draft.techPhone);
if (techPhone) {
diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
index d5ba9f78..49d3e157 100644
--- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
+++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts
@@ -1,5 +1,7 @@
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
+import { serializeAdditionalContactsForPatch } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
export type BoardFieldOp = {
@@ -37,6 +39,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
+ {
+ key: "additionalContacts",
+ field: "additionalContacts",
+ toValue: (value) => serializeAdditionalContactsForPatch(value as WorkOrderAdditionalContact[]),
+ },
];
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
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/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index f845f009..8dfe70e0 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -1,3 +1,4 @@
+import { readAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
@@ -357,6 +358,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
pocName: readString(item, "pocName", "PocName", "POCName"),
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
+ additionalContacts: readAdditionalContacts(item),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
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-additional-contact.ts b/src/domain/work-orders/types/work-order-additional-contact.ts
new file mode 100644
index 00000000..dd5d7abf
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-additional-contact.ts
@@ -0,0 +1,11 @@
+export interface WorkOrderAdditionalContact {
+ name: string;
+ phone: string;
+ notes?: string;
+}
+
+export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
+ name: "",
+ phone: "",
+ notes: "",
+};
diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts
index ab3737b2..dda17df8 100644
--- a/src/domain/work-orders/types/work-order-board-detail.ts
+++ b/src/domain/work-orders/types/work-order-board-detail.ts
@@ -1,4 +1,5 @@
import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail";
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
@@ -26,6 +27,7 @@ export interface WorkOrderBoardDetailInfoApi {
pocName?: string | null;
pocPhone?: string | null;
pocNotes?: string | null;
+ additionalContacts?: WorkOrderAdditionalContact[] | null;
lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null;
legacyStatus?: string | null;
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
index 9d6e822e..5011b67b 100644
--- a/src/domain/work-orders/types/work-order-board.ts
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -1,4 +1,5 @@
import type { DateRangeKey } from "./work-order-advanced-filters";
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WorkOrderTableRow } from "./work-order-table-row";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
@@ -108,6 +109,7 @@ export interface WorkOrderBoardCreatePayload {
pocName?: string;
pocPhone?: string;
pocNotes?: string;
+ additionalContacts?: WorkOrderAdditionalContact[];
techPhone?: string;
vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */
@@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
| "serviceNotes"
| "extraServices"
| "flagColor"
+ | "additionalContacts"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
@@ -159,6 +162,7 @@ export interface WorkOrderBoardRowApi {
pocName?: string | null;
pocPhone?: string | null;
pocNotes?: string | null;
+ additionalContacts?: WorkOrderAdditionalContact[] | null;
lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null;
legacyStatus?: string | null;
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/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
index d4dc3782..4b066382 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -1,3 +1,4 @@
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift";
@@ -46,6 +47,7 @@ export interface WorkOrderTableRow {
pocName: string;
pocPhone: string;
pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
dueDate: string;
rescheduleCount: number;
carriedOver: number;
@@ -94,6 +96,7 @@ export interface WorkOrderTableDraft {
pocName: string;
pocPhone: string;
pocNotes: string;
+ additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean;
targetWeek: string;
/** Manual Add-On override when schedule is empty at inline create. */
@@ -124,6 +127,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts
index f0cc1c79..cc4c1ad7 100644
--- a/src/domain/work-orders/types/work-order-wizard.ts
+++ b/src/domain/work-orders/types/work-order-wizard.ts
@@ -1,3 +1,4 @@
+import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
export type { WorkOrderSeverity } from "./work-order-severity";
@@ -50,6 +51,8 @@ export interface WorkOrderWizardDraft {
pocName: string;
pocPhone: string;
pocNotes: string;
+ /** Secondary POCs beyond primary pocName/pocPhone/pocNotes. */
+ additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean;
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
targetWeek?: string;
@@ -89,6 +92,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
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/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts
index 2b4a3226..0223f99d 100644
--- a/src/domain/work-orders/utils/slide-over-draft.ts
+++ b/src/domain/work-orders/utils/slide-over-draft.ts
@@ -1,3 +1,4 @@
+import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
@@ -5,8 +6,9 @@ import type {
/**
* Fields compared for dirty state and included in Save patch.
- * Only keys that map to board PATCH (or required companions like dispatcherName / vendor labels).
- * POC, locationId, and tech phone are not board-editable — exclude so Save cannot no-op.
+ * Only keys that map to board PATCH (or required companions like dispatcherName).
+ * Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
+ * additionalContacts is board-editable (SH-218 PATCH field).
*/
export const SLIDE_OVER_EDIT_KEYS = [
"woNumber",
@@ -23,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"pm",
"serviceNotes",
"extraServices",
+ "additionalContacts",
"vendorId",
// company/tech are display companions; board persists vendor via vendorId only
"status",
@@ -37,6 +40,23 @@ function sameExtraServices(a: string[] | undefined, b: string[] | undefined): bo
return left.every((value, index) => value === right[index]);
}
+function sameAdditionalContacts(
+ a: WorkOrderAdditionalContact[] | undefined,
+ b: WorkOrderAdditionalContact[] | undefined,
+): boolean {
+ const left = a ?? [];
+ const right = b ?? [];
+ if (left.length !== right.length) return false;
+ return left.every((contact, index) => {
+ const other = right[index];
+ return (
+ contact.name === other.name &&
+ contact.phone === other.phone &&
+ (contact.notes ?? "") === (other.notes ?? "")
+ );
+ });
+}
+
export function isSlideOverDraftDirty(
draft: WorkOrderTableRow,
baseline: WorkOrderTableRow,
@@ -46,6 +66,12 @@ export function isSlideOverDraftDirty(
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true;
continue;
}
+ if (key === "additionalContacts") {
+ if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) {
+ return true;
+ }
+ continue;
+ }
if (draft[key] !== baseline[key]) return true;
}
return false;
@@ -64,6 +90,12 @@ export function buildSlideOverPatch(
}
continue;
}
+ if (key === "additionalContacts") {
+ if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) {
+ patch.additionalContacts = draft.additionalContacts;
+ }
+ continue;
+ }
if (draft[key] !== baseline[key]) {
(patch as Record)[key] = draft[key];
}
diff --git a/src/domain/work-orders/utils/week-day-nav-utils.ts b/src/domain/work-orders/utils/week-day-nav-utils.ts
new file mode 100644
index 00000000..073cf231
--- /dev/null
+++ b/src/domain/work-orders/utils/week-day-nav-utils.ts
@@ -0,0 +1,88 @@
+import type {
+ WorkOrderDayGroup,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
+import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
+import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+const DAY_TAB_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
+
+export function weekdaysMonFri(weekMonday: string): string[] {
+ return Array.from({ length: 5 }, (_, index) => addDaysIso(weekMonday, index));
+}
+
+export function firstWeekday(weekMonday: string): string {
+ return weekMonday;
+}
+
+/** Maps a scheduled date onto the Mon–Fri tab that can show it (weekends fall back to Monday). */
+export function scheduledDayTab(dateIso: string): { weekMonday: string; selectedDayIso: string } {
+ const day = dateIso.slice(0, 10);
+ const weekMonday = mondayOf(day);
+ const days = weekdaysMonFri(weekMonday);
+ return {
+ weekMonday,
+ selectedDayIso: days.includes(day) ? day : firstWeekday(weekMonday),
+ };
+}
+
+export function isUnassignedQueueReady(
+ unassignedLoading: boolean,
+ unassignedError: Error | null,
+): boolean {
+ return !unassignedLoading && unassignedError == null;
+}
+
+export function isDayScopedSearchEmpty(args: {
+ searchActive: boolean;
+ unassignedCount: number;
+ dayItemCount: number;
+ unassignedQueueReady: boolean;
+}): boolean {
+ return (
+ args.searchActive &&
+ args.unassignedQueueReady &&
+ args.unassignedCount === 0 &&
+ args.dayItemCount === 0
+ );
+}
+
+export function initialSelectedDay(weekMonday: string, today = todayIso()): string {
+ if (isLiveWeek(weekMonday) && isDateInWeek(today, weekMonday)) {
+ return today;
+ }
+ return firstWeekday(weekMonday);
+}
+
+export function formatDayTabLabel(iso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
+ return "";
+ }
+ const day = new Date(`${iso}T12:00:00`);
+ const [, month, date] = iso.split("-");
+ return `${DAY_TAB_WEEKDAYS[day.getDay()]} · ${month}/${date}`;
+}
+
+export function rowMatchesSelectedDay(
+ row: Pick,
+ selectedDayIso: string,
+): boolean {
+ return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
+}
+
+export function buildSelectedDayGroup(
+ weekMonday: string,
+ selectedDayIso: string,
+ scheduled: WorkOrderTableRow[],
+): WorkOrderDayGroup {
+ const days = weekdaysMonFri(weekMonday);
+ const date = days.includes(selectedDayIso) ? selectedDayIso : firstWeekday(weekMonday);
+ const [, month, day] = date.split("-");
+ return {
+ key: `${month}/${day}`,
+ label: formatDayTabLabel(date),
+ date,
+ items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
+ };
+}
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/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
index 72fa8e30..f36cc442 100644
--- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -58,6 +58,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
index 18bf4862..fd628353 100644
--- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -70,6 +70,7 @@ function baseRow(): WorkOrderTableRow {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx
new file mode 100644
index 00000000..b1627674
--- /dev/null
+++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx
@@ -0,0 +1,165 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
+import { buildSlideOverPatch } from "@/domain/work-orders/utils/slide-over-draft";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
+ SiteCell: ({
+ additionalContacts,
+ onSave,
+ }: {
+ additionalContacts?: { name: string; phone: string }[];
+ onSave: (patch: {
+ site: string;
+ location: string;
+ locationId: string;
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ additionalContacts?: { name: string; phone: string }[];
+ }) => void;
+ }) => (
+
+ onSave({
+ site: "SEA1",
+ location: "Seattle",
+ locationId: "loc-1",
+ pocName: "Pat",
+ pocPhone: "111",
+ pocNotes: "",
+ additionalContacts: [{ name: "Backup", phone: "555-0101" }],
+ })
+ }
+ >
+ save site {additionalContacts?.length ?? 0}
+
+ ),
+}));
+
+vi.mock(
+ "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
+ () => ({
+ EditableWONumberCell: () => null,
+ }),
+);
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
+ TypeCell: () => null,
+}));
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
+ AssignedToCell: () => null,
+}));
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
+ DateCell: () => null,
+}));
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
+ ApptTimeCell: () => null,
+}));
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
+ PMTypeCell: () => null,
+}));
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
+ VendorCell: () => null,
+}));
+vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
+ StatusCell: () => null,
+}));
+
+function baseRow(overrides: Partial = {}): WorkOrderTableRow {
+ return {
+ id: 42,
+ woNumber: "WO-42",
+ site: "SEA1",
+ type: "PM",
+ severity: null,
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-07-20",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "07/20",
+ dayLabel: "Mon",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ company: "Vendor Co",
+ tech: "Tech",
+ techPhone: "",
+ vendorId: "v1",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ additionalContacts: [],
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ isAddOn: false,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "v1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+describe("SlideOverInfoTabEditView additional contacts", () => {
+ it("passes additionalContacts into SiteCell and persists them on save", () => {
+ const onDraftChange = vi.fn();
+ const contacts = [{ name: "Existing", phone: "555-0000" }];
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /save site 1/i }));
+
+ expect(onDraftChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ additionalContacts: [{ name: "Backup", phone: "555-0101" }],
+ }),
+ );
+ });
+
+ it("includes additionalContacts in the slide-over board PATCH ops", () => {
+ const baseline = baseRow();
+ const draft = baseRow({
+ additionalContacts: [{ name: "Backup", phone: "555-0101", notes: "Gate" }],
+ });
+ const patch = buildSlideOverPatch(draft, baseline);
+
+ expect(expandTablePatchToBoardFieldOps(patch)).toEqual([
+ {
+ field: "additionalContacts",
+ value: JSON.stringify([{ name: "Backup", phone: "555-0101", notes: "Gate" }]),
+ },
+ ]);
+ });
+});
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/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
index d886232a..a3755204 100644
--- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
+++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx
@@ -50,6 +50,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
+ additionalContacts: [],
...overrides,
};
}
diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
index a7992bac..80a9b011 100644
--- a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
+++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
@@ -45,6 +45,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
index dc0adb78..b3f72c52 100644
--- a/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-site-dialog-state.test.tsx
@@ -42,6 +42,7 @@ function renderSiteDialogState() {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
sites: SITES,
createMode: true,
onSave: vi.fn(),
diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
index 9b933157..e4d7d670 100644
--- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
@@ -35,6 +35,7 @@ function baseInfo(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts
index df563025..e9b52a4a 100644
--- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts
+++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts
@@ -26,7 +26,7 @@ describe("useWoTableRowHandlers onDuplicateFound", () => {
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-07-20",
- onWeekChange: vi.fn(),
+ onNavigateToDay: vi.fn(),
onOpenSlideOver,
onOpenMedia: vi.fn(),
onPatch: vi.fn(),
diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts
new file mode 100644
index 00000000..afbf434c
--- /dev/null
+++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-schedule.test.ts
@@ -0,0 +1,116 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
+import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+
+const { rescheduleNotify, weekMoveNotify } = vi.hoisted(() => ({
+ rescheduleNotify: vi.fn(),
+ weekMoveNotify: vi.fn(),
+}));
+
+vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({
+ rescheduleNotify,
+ weekMoveNotify,
+}));
+
+function buildTableData(triggerFlash = vi.fn()) {
+ return {
+ dragState: { current: { dragId: null, overId: null } },
+ setDragViz: vi.fn(),
+ reorderRows: vi.fn(),
+ endDrag: vi.fn(),
+ triggerFlash,
+ setSelectedId: vi.fn(),
+ } as never;
+}
+
+describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
+ it("selects the scheduled weekday in the same week so the row stays visible", () => {
+ const onNavigateToDay = vi.fn();
+ const onPatch = vi.fn();
+ const triggerFlash = vi.fn();
+
+ const { result } = renderHook(() =>
+ useWoTableRowHandlers({
+ tableData: buildTableData(triggerFlash),
+ weekMonday: "2026-05-18",
+ onNavigateToDay,
+ onOpenSlideOver: vi.fn(),
+ onOpenMedia: vi.fn(),
+ onPatch,
+ onScheduleHighlight: vi.fn(),
+ }),
+ );
+
+ result.current.onScheduleDate("wo-1", "2026-05-21");
+
+ expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
+ expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
+ expect(triggerFlash).toHaveBeenCalledWith("wo-1");
+ expect(rescheduleNotify).not.toHaveBeenCalled();
+ });
+
+ it("sends cross-week view-it navigation to the target day, not Monday", () => {
+ const onNavigateToDay = vi.fn();
+
+ const { result } = renderHook(() =>
+ useWoTableRowHandlers({
+ tableData: buildTableData(),
+ weekMonday: "2026-05-18",
+ onNavigateToDay,
+ onOpenSlideOver: vi.fn(),
+ onOpenMedia: vi.fn(),
+ onPatch: vi.fn(),
+ onScheduleHighlight: vi.fn(),
+ }),
+ );
+
+ result.current.onScheduleDate("wo-1", "2026-05-27");
+
+ expect(onNavigateToDay).not.toHaveBeenCalled();
+ expect(rescheduleNotify).toHaveBeenCalledWith(
+ "2026-05-27",
+ expect.objectContaining({ onNavigate: expect.any(Function) }),
+ );
+
+ const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
+ onNavigate();
+ expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27");
+ });
+
+ it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
+ const onNavigateToDay = vi.fn();
+ const onOpenAdvancedFilters = vi.fn();
+ const onPatch = vi.fn();
+
+ const { result } = renderHook(() =>
+ useWoTableRowHandlers({
+ tableData: buildTableData(),
+ weekMonday: "2026-05-18",
+ onNavigateToDay,
+ onOpenAdvancedFilters,
+ onOpenSlideOver: vi.fn(),
+ onOpenMedia: vi.fn(),
+ onPatch,
+ onScheduleHighlight: vi.fn(),
+ }),
+ );
+
+ result.current.onScheduleWeek("wo-1", "2026-05-25");
+
+ expect(onPatch).toHaveBeenCalledWith(
+ "wo-1",
+ expect.objectContaining({
+ scheduledOn: "",
+ scheduleWeekOnly: true,
+ targetWeek: "2026-05-25",
+ }),
+ );
+ expect(weekMoveNotify).toHaveBeenCalled();
+
+ const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
+ onNavigate();
+ expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-25");
+ expect(onOpenAdvancedFilters).toHaveBeenCalled();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/week-day-nav.test.tsx b/src/test/app/(protected)/workorders/week-day-nav.test.tsx
new file mode 100644
index 00000000..935df45f
--- /dev/null
+++ b/src/test/app/(protected)/workorders/week-day-nav.test.tsx
@@ -0,0 +1,113 @@
+import { act, render, renderHook, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
+import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
+import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
+import { firstWeekday } from "@/domain/work-orders/utils/week-day-nav-utils";
+
+vi.mock("@/domain/work-orders/utils/wizard-date-utils", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ todayIso: () => "2026-08-19",
+ };
+});
+
+function WeekDayNavHarness() {
+ const [weekMonday, setWeekMonday] = useState("2026-05-18");
+ const [selectedDayIso, setSelectedDayIso] = useState("2026-05-20");
+
+ const handleWeekChange = (mondayIso: string) => {
+ setWeekMonday(mondayIso);
+ setSelectedDayIso(firstWeekday(mondayIso));
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+describe("WeekSelector and DayTabs (SH-240)", () => {
+ it("renders Mon–Fri tabs for the active week", () => {
+ render(
+ undefined} />,
+ );
+
+ expect(screen.getByRole("tab", { name: "Mon · 05/18" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByRole("tab", { name: "Tue · 05/19" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Wed · 05/20" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Thu · 05/21" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Fri · 05/22" })).toBeInTheDocument();
+ });
+
+ it("selects a day tab without expanding other days", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("tab", { name: "Thu · 05/21" }));
+ expect(screen.getByRole("tab", { name: "Thu · 05/21" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByRole("tab", { name: "Wed · 05/20" })).toHaveAttribute(
+ "aria-selected",
+ "false",
+ );
+ });
+
+ it("resets to Monday when the week picker selects another week", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "Week 5/18–5/22" }));
+ await user.click(screen.getByRole("button", { name: /May 11/i }));
+
+ expect(screen.getByRole("button", { name: "Week 5/11–5/15" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Mon · 05/11" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(screen.getByRole("tab", { name: "Fri · 05/15" })).toBeInTheDocument();
+ expect(screen.queryByRole("tab", { name: "Mon · 05/18" })).not.toBeInTheDocument();
+ });
+});
+
+describe("useWorkOrdersListFilters day state (SH-240)", () => {
+ it("starts on today in the live week and resets to Monday on week change", () => {
+ const { result } = renderHook(() => useWorkOrdersListFilters());
+
+ expect(result.current.weekMonday).toBe("2026-08-17");
+ expect(result.current.selectedDayIso).toBe("2026-08-19");
+
+ act(() => {
+ result.current.setWeekMonday("2026-08-10");
+ });
+
+ expect(result.current.weekMonday).toBe("2026-08-10");
+ expect(result.current.selectedDayIso).toBe("2026-08-10");
+ });
+
+ it("follows a scheduled date onto that weekday instead of resetting to Monday", () => {
+ const { result } = renderHook(() => useWorkOrdersListFilters());
+
+ act(() => {
+ result.current.navigateToDay("2026-05-20");
+ });
+
+ expect(result.current.weekMonday).toBe("2026-05-18");
+ expect(result.current.selectedDayIso).toBe("2026-05-20");
+ });
+});
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/wo-table-search-empty.test.tsx b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx
new file mode 100644
index 00000000..410f0c8d
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wo-table-search-empty.test.tsx
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from "vitest";
+import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
+import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
+import { renderWithProviders } from "@/test/test-utils";
+
+vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
+ useLocationSites: () => ({ data: [] }),
+}));
+
+vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
+ useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
+}));
+
+function tableData(
+ overrides: Partial = {},
+): ReturnTypeOfUseWorkOrdersTableData {
+ return {
+ rows: [],
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ refetch: vi.fn(),
+ advActive: false,
+ advResults: [],
+ unassigned: [],
+ unassignedError: null,
+ unassignedLoading: false,
+ unscheduled: [],
+ displayGroups: [{ key: "05/18", label: "Mon · 05/18", date: "2026-05-18", items: [] }],
+ searchActive: true,
+ noSearchResults: true,
+ isEmptyWeek: false,
+ todayKey: null,
+ draftDayKey: null,
+ draft: { locationId: "", site: "" },
+ setDraft: vi.fn(),
+ unassignedOpen: true,
+ setUnassignedOpen: vi.fn(),
+ unschedOpen: true,
+ setUnschedOpen: vi.fn(),
+ selectedId: null,
+ setSelectedId: vi.fn(),
+ selectedRow: null,
+ flashId: null,
+ triggerFlash: vi.fn(),
+ dragViz: null,
+ setDragViz: vi.fn(),
+ dragState: { current: { dragId: null, overId: null } },
+ patchRow: vi.fn(),
+ clearRowPatch: vi.fn(),
+ reorderRows: vi.fn(),
+ endDrag: vi.fn(),
+ startDayDraft: vi.fn(),
+ cancelDraft: vi.fn(),
+ addRow: vi.fn(),
+ scopeTotalCount: 0,
+ totalCount: 0,
+ search: "no-match",
+ ...overrides,
+ } as ReturnTypeOfUseWorkOrdersTableData;
+}
+
+function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
+ return renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+}
+
+describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => {
+ it("shows empty search only after the Unassigned queue has settled empty", () => {
+ const { getByText, queryByText } = renderTable(tableData());
+
+ expect(getByText("No work orders match your search")).toBeInTheDocument();
+ expect(queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
+ });
+
+ it("keeps Unassigned loading visible instead of a premature empty search", () => {
+ const { getByText, queryByText } = renderTable(
+ tableData({
+ noSearchResults: true,
+ unassignedLoading: true,
+ }),
+ );
+
+ expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
+ expect(getByText(/Loading unassigned work orders/)).toBeInTheDocument();
+ });
+
+ it("keeps Unassigned error visible instead of hiding it behind empty search", () => {
+ const { getByText, queryByText } = renderTable(
+ tableData({
+ noSearchResults: true,
+ unassignedError: new Error("queue unavailable"),
+ }),
+ );
+
+ expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
+ expect(getByText("queue unavailable")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx
index 66a61208..0913489b 100644
--- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx
@@ -59,6 +59,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
+ additionalContacts: [],
...overrides,
};
}
@@ -179,7 +180,7 @@ function UnassignedAssignHarness({
}
describe("WoTableBody Unassigned queue (SH-165)", () => {
- it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => {
+ it("renders Unassigned above the selected day without Unscheduled (SH-240)", () => {
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
const undatedUnassigned = makeRow({
id: "us1",
@@ -234,26 +235,21 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
const unassignedHeader = screen.getByRole("button", {
name: "Unassigned, 2 work orders, expanded",
});
- const unscheduledHeader = screen.getByRole("button", {
- name: "Unscheduled, 1 work order, expanded",
- });
const dayLabel = screen.getByText("TUE · 06/02");
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
- expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
- Node.DOCUMENT_POSITION_FOLLOWING,
- );
- expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
+ expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
+ expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
- expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument();
+ expect(screen.queryByTestId("row-us-assigned")).not.toBeInTheDocument();
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
- expect(screen.getAllByTestId(/row-/)).toHaveLength(4);
+ expect(screen.getAllByTestId(/row-/)).toHaveLength(3);
});
it("collapses and expands the Unassigned section via the disclosure control", async () => {
diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
index 36b6c520..afa1a42b 100644
--- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
@@ -7,8 +7,6 @@ import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_component
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
-const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
-
const unscheduledRow: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
@@ -39,6 +37,7 @@ const unscheduledRow: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-07-15",
rescheduleCount: 0,
carriedOver: 0,
@@ -66,6 +65,8 @@ const handlers: WoTableRowHandlers = {
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onOpenSlideOver: vi.fn(),
+ onOpenCompDoc: vi.fn(),
+ onOpenUplifts: vi.fn(),
onReorder: vi.fn(),
onDragStart: vi.fn(),
onDragOver: vi.fn(),
@@ -73,55 +74,47 @@ const handlers: WoTableRowHandlers = {
onDragEnd: vi.fn(),
};
-/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
-function renderUnscheduledHarness() {
- const tableData = {
- advActive: false,
- unassigned: [],
- unassignedError: null,
- unassignedLoading: false,
- unassignedOpen: true,
- setUnassignedOpen: vi.fn(),
- unscheduled: [unscheduledRow],
- unschedOpen: true,
- draftDayKey: null,
- displayGroups: [],
- todayKey: null,
- startDayDraft: vi.fn(),
- setUnschedOpen: vi.fn(),
- } as unknown as ReturnTypeOfUseWorkOrdersTableData;
+describe("WoTableBody unscheduled exclusion (SH-240)", () => {
+ it("does not render Unscheduled rows in the day-tab board view", () => {
+ const tableData = {
+ advActive: false,
+ unassigned: [],
+ unassignedError: null,
+ unassignedLoading: false,
+ unassignedOpen: true,
+ setUnassignedOpen: vi.fn(),
+ unscheduled: [unscheduledRow],
+ unschedOpen: true,
+ draftDayKey: null,
+ displayGroups: [],
+ todayKey: null,
+ startDayDraft: vi.fn(),
+ setUnschedOpen: vi.fn(),
+ } as unknown as ReturnTypeOfUseWorkOrdersTableData;
- const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
-
-
-
- );
+ const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
+
+
+
+ );
- return renderWithProviders(
- ,
- { withAuth: false },
- );
-}
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
-describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
- it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
- renderUnscheduledHarness();
-
- const scheduleOn = screen.getByRole("button", { name: /^—$/ });
- const dueDate = screen.getByRole("button", { name: /07-15/ });
-
- expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
- expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
+ expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
+ expect(screen.queryByText("WO-42")).not.toBeInTheDocument();
});
});
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/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
index f49cf4a1..9597efee 100644
--- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
+++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
@@ -78,6 +78,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
index 4230eb74..1afe8888 100644
--- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
@@ -54,6 +54,7 @@ const baseRow: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
index cffbaedf..a8e0c15e 100644
--- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
@@ -48,7 +48,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: advApplied ? 1 : 0,
weekMonday: "2026-07-20",
+ selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
+ setSelectedDayIso: vi.fn(),
+ navigateToDay: vi.fn(),
dispatcherIds: new Set(),
setDispatcherIds: vi.fn(),
typeFilter: "All",
@@ -251,8 +254,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
});
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Week 7/20–7/24" })).toBeDisabled();
+ expect(screen.getByRole("tab", { name: "Mon · 07/20" })).toBeDisabled();
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
expect(screen.getByText("42 work orders")).toBeInTheDocument();
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index 08019764..42074864 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -44,7 +44,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: 0,
weekMonday: "2026-07-20",
+ selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
+ setSelectedDayIso: vi.fn(),
+ navigateToDay: vi.fn(),
dispatcherIds: new Set(),
setDispatcherIds: vi.fn(),
typeFilter: "All",
@@ -97,6 +100,7 @@ const canceledRow: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
diff --git a/src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts b/src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts
new file mode 100644
index 00000000..9b76a8b5
--- /dev/null
+++ b/src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import {
+ isIncompleteAdditionalContact,
+ normalizeAdditionalContacts,
+ readAdditionalContacts,
+ serializeAdditionalContactsForPatch,
+} from "@/domain/work-orders/mappers/additional-contacts-mapper";
+
+describe("readAdditionalContacts", () => {
+ it("returns [] when field is missing", () => {
+ expect(readAdditionalContacts({ id: 1 })).toEqual([]);
+ });
+
+ it("maps camelCase and PascalCase arrays", () => {
+ expect(
+ readAdditionalContacts({
+ additionalContacts: [{ name: "Alex", phone: "555-0100", notes: "Gate code" }],
+ }),
+ ).toEqual([{ name: "Alex", phone: "555-0100", notes: "Gate code" }]);
+
+ expect(
+ readAdditionalContacts({
+ AdditionalContacts: [{ Name: "Sam", Phone: "555-0200" }],
+ }),
+ ).toEqual([{ name: "Sam", phone: "555-0200" }]);
+ });
+
+ it("drops empty rows", () => {
+ expect(
+ readAdditionalContacts({
+ additionalContacts: [
+ { name: "", phone: "" },
+ { name: "Pat", phone: "111" },
+ ],
+ }),
+ ).toEqual([{ name: "Pat", phone: "111" }]);
+ });
+});
+
+describe("normalizeAdditionalContacts", () => {
+ it("trims and omits empty notes", () => {
+ expect(
+ normalizeAdditionalContacts([
+ { name: " Alex ", phone: " 555 ", notes: " " },
+ { name: "", phone: "" },
+ ]),
+ ).toEqual([{ name: "Alex", phone: "555" }]);
+ });
+
+ it("returns undefined when all rows are empty", () => {
+ expect(normalizeAdditionalContacts([{ name: "", phone: "" }])).toBeUndefined();
+ });
+
+ it("drops name-only and phone-only rows", () => {
+ expect(
+ normalizeAdditionalContacts([
+ { name: "Alex", phone: "" },
+ { name: "", phone: "555-0100" },
+ { name: "Pat", phone: "111" },
+ ]),
+ ).toEqual([{ name: "Pat", phone: "111" }]);
+ });
+});
+
+describe("isIncompleteAdditionalContact", () => {
+ it("is true when exactly one of name or phone is filled", () => {
+ expect(isIncompleteAdditionalContact({ name: "Alex", phone: "" })).toBe(true);
+ expect(isIncompleteAdditionalContact({ name: "", phone: "555" })).toBe(true);
+ expect(isIncompleteAdditionalContact({ name: "Alex", phone: "555" })).toBe(false);
+ expect(isIncompleteAdditionalContact({ name: "", phone: "" })).toBe(false);
+ });
+});
+
+describe("serializeAdditionalContactsForPatch", () => {
+ it("JSON-stringifies normalized contacts for board PATCH", () => {
+ expect(
+ serializeAdditionalContactsForPatch([
+ { name: "Alex", phone: "555-0100" },
+ { name: "", phone: "" },
+ ]),
+ ).toBe('[{"name":"Alex","phone":"555-0100"}]');
+ });
+
+ it("serializes empty array when nothing to persist", () => {
+ expect(serializeAdditionalContactsForPatch([])).toBe("[]");
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
index 1bf399ce..edfd4067 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
@@ -41,6 +41,10 @@ const SAMPLE_DETAIL = {
pm: "HVAC PM",
serviceNotes: "Check filters",
extraServices: ["Filter Change"],
+ additionalContacts: [
+ { name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
+ { name: "Security Desk", phone: "+1 555-0102" },
+ ],
docStatus: 2,
flagColor: "#EF4444",
primaryDispatchId: 789,
@@ -124,6 +128,10 @@ describe("mapWorkOrderBoardDetail", () => {
expect(detail.info.tech).toBe("Mike Tech");
expect(detail.info.pm).toBe("HVAC PM");
expect(detail.info.location).toBe("Main Warehouse");
+ expect(detail.info.additionalContacts).toEqual([
+ { name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
+ { name: "Security Desk", phone: "+1 555-0102" },
+ ]);
expect(detail.completion.docStatus).toBe("No");
expect(detail.completion.template?.name).toBe("HVAC PM Completion");
diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
index c766362d..eff3b3fc 100644
--- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts
@@ -161,6 +161,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
pocNotes: "Call before arrival",
+ additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
+
techPhone: "+1 555-0199",
vendorNotes: "Gate code 4421",
@@ -204,12 +206,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
vendorNotes: "Gate code 4421",
locationId: 12,
+
+ additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
});
expect(payload.trade).toBeUndefined();
- expect(payload.extraServices).toBeUndefined();
-
expect(payload.description).toBeUndefined();
expect(payload.scheduleWeekOnly).toBeUndefined();
@@ -376,7 +378,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.vendorNotes).toBe("Also not in description");
});
- it("maps service notes as structured fields and omits extraServices from wizard create", () => {
+ it("maps service notes as structured fields and omits extraServices on create", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
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 c0fa5524..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,27 @@ 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: "" },
+ ]);
+ });
+
+ it("emits additionalContacts as serialized JSON for board PATCH", () => {
+ expect(
+ expandTablePatchToBoardFieldOps({
+ additionalContacts: [{ name: "Alex", phone: "555-0100", notes: "Gate" }],
+ }),
+ ).toEqual([
+ {
+ field: "additionalContacts",
+ value: JSON.stringify([{ name: "Alex", phone: "555-0100", notes: "Gate" }]),
+ },
]);
});
diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
index 107d63f0..676e26a3 100644
--- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
+++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts
@@ -164,6 +164,7 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(row.serviceNotes).toBe("service notes");
expect(row.extraServices).toEqual(["Filter change", "Coil clean"]);
expect(row.docStatus).toBe("Yes");
+ expect(row.additionalContacts).toEqual([]);
expect(row.dayGroup).toBe("07/15");
expect(row.flagColor).toBe("#EF4444");
});
@@ -175,6 +176,15 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6");
});
+ it("maps additionalContacts from board rows", () => {
+ expect(
+ mapWorkOrderTableRow({
+ id: 1,
+ additionalContacts: [{ name: "Alex", phone: "555-0100" }],
+ }).additionalContacts,
+ ).toEqual([{ name: "Alex", phone: "555-0100" }]);
+ });
+
it("maps uplift summary and pending flag from API payload", () => {
const row = mapWorkOrderTableRow({
id: 303,
diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
index 80f4092b..73729279 100644
--- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
+++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx
@@ -83,6 +83,7 @@ const BASE_ROW: WorkOrderTableRow = {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
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/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
index fa9db9e1..885b7780 100644
--- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
@@ -38,6 +38,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
@@ -63,6 +64,7 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
+ expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
@@ -112,4 +114,14 @@ describe("slide-over-draft", () => {
extraServices: ["HVAC"],
});
});
+
+ it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => {
+ const row = baseRow();
+ const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];
+ const draft = baseRow({ additionalContacts: contacts });
+
+ expect(isSlideOverDraftDirty(draft, row)).toBe(true);
+ expect(buildSlideOverPatch(draft, row)).toEqual({ additionalContacts: contacts });
+ expect(isSlideOverDraftDirty(baseRow({ additionalContacts: [] }), row)).toBe(false);
+ });
});
diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
index bb1c5d8a..a85a291a 100644
--- a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
@@ -59,6 +59,7 @@ function row(overrides: Partial = {}): WorkOrderTableRow {
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
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 678774b5..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
@@ -54,6 +54,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
pocName: "",
pocPhone: "",
pocNotes: "",
+ additionalContacts: [],
dueDate: "2026-06-30",
rescheduleCount: 0,
carriedOver: 0,
@@ -268,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/week-day-nav-utils.test.ts b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
new file mode 100644
index 00000000..0439dc61
--- /dev/null
+++ b/src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
@@ -0,0 +1,181 @@
+import { describe, expect, it } from "vitest";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
+import {
+ buildSelectedDayGroup,
+ firstWeekday,
+ formatDayTabLabel,
+ initialSelectedDay,
+ isDayScopedSearchEmpty,
+ isUnassignedQueueReady,
+ rowMatchesSelectedDay,
+ scheduledDayTab,
+ weekdaysMonFri,
+} from "@/domain/work-orders/utils/week-day-nav-utils";
+import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+function row(overrides: Partial): WorkOrderTableRow {
+ return {
+ id: "1",
+ woNumber: "1",
+ site: "",
+ type: "PM",
+ severity: null,
+ dispatcherId: "d1",
+ dispatcherName: "D",
+ scheduledOn: "",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "",
+ locationId: "",
+ pm: "",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ additionalContacts: [],
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ isAddOn: false,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "",
+ dispatchRowVersion: "",
+ flagColor: null,
+ ...overrides,
+ };
+}
+
+describe("week-day-nav-utils (SH-240)", () => {
+ it("lists Mon–Fri of the active week", () => {
+ expect(weekdaysMonFri("2026-05-18")).toEqual([
+ "2026-05-18",
+ "2026-05-19",
+ "2026-05-20",
+ "2026-05-21",
+ "2026-05-22",
+ ]);
+ });
+
+ it("formats day tabs as Title · MM/DD", () => {
+ expect(formatDayTabLabel("2026-05-18")).toBe("Mon · 05/18");
+ expect(formatDayTabLabel("2026-05-22")).toBe("Fri · 05/22");
+ });
+
+ it("resets to Monday when the week changes", () => {
+ expect(firstWeekday("2026-05-25")).toBe("2026-05-25");
+ });
+
+ it("selects today on the live week when today is a weekday", () => {
+ const liveMonday = currentWeekMonday();
+ const wednesday = addDaysIso(liveMonday, 2);
+ expect(initialSelectedDay(liveMonday, wednesday)).toBe(wednesday);
+ });
+
+ it("selects Monday on a non-live week", () => {
+ expect(initialSelectedDay("2026-05-18", "2026-05-20")).toBe("2026-05-18");
+ });
+
+ it("excludes unscheduled and other days from the selected day group", () => {
+ const monday = row({
+ id: "mon",
+ scheduledOn: "2026-05-18",
+ dayGroup: "05/18",
+ dayLabel: "MON · 05/18",
+ });
+ const wednesday = row({
+ id: "wed",
+ scheduledOn: "2026-05-20",
+ dayGroup: "05/20",
+ dayLabel: "WED · 05/20",
+ });
+ const unscheduled = row({ id: "us", scheduledOn: "", scheduleWeekOnly: true });
+
+ expect(rowMatchesSelectedDay(unscheduled, "2026-05-18")).toBe(false);
+ expect(rowMatchesSelectedDay(monday, "2026-05-18")).toBe(true);
+
+ const group = buildSelectedDayGroup("2026-05-18", "2026-05-18", [
+ monday,
+ wednesday,
+ unscheduled,
+ ]);
+ expect(group.date).toBe("2026-05-18");
+ expect(group.label).toBe("Mon · 05/18");
+ expect(group.items.map((item) => item.id)).toEqual(["mon"]);
+ });
+
+ it("maps a scheduled weekday onto its week and day tab", () => {
+ expect(scheduledDayTab("2026-05-20")).toEqual({
+ weekMonday: "2026-05-18",
+ selectedDayIso: "2026-05-20",
+ });
+ });
+
+ it("treats search as empty only when the selected day and Unassigned have no hits", () => {
+ expect(
+ isDayScopedSearchEmpty({
+ searchActive: true,
+ unassignedCount: 0,
+ dayItemCount: 0,
+ unassignedQueueReady: true,
+ }),
+ ).toBe(true);
+ expect(
+ isDayScopedSearchEmpty({
+ searchActive: true,
+ unassignedCount: 0,
+ dayItemCount: 1,
+ unassignedQueueReady: true,
+ }),
+ ).toBe(false);
+ expect(
+ isDayScopedSearchEmpty({
+ searchActive: true,
+ unassignedCount: 2,
+ dayItemCount: 0,
+ unassignedQueueReady: true,
+ }),
+ ).toBe(false);
+ expect(
+ isDayScopedSearchEmpty({
+ searchActive: false,
+ unassignedCount: 0,
+ dayItemCount: 0,
+ unassignedQueueReady: true,
+ }),
+ ).toBe(false);
+ });
+
+ it("does not treat search as empty while the Unassigned queue is loading or failed", () => {
+ expect(isUnassignedQueueReady(true, null)).toBe(false);
+ expect(isUnassignedQueueReady(false, new Error("queue unavailable"))).toBe(false);
+ expect(isUnassignedQueueReady(false, null)).toBe(true);
+ expect(
+ isDayScopedSearchEmpty({
+ searchActive: true,
+ unassignedCount: 0,
+ dayItemCount: 0,
+ unassignedQueueReady: false,
+ }),
+ ).toBe(false);
+ });
+});
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("");
+ },
+ );
+});