Merge branch 'dev' into feat/sh-250-fe-additive-add

This commit is contained in:
Alexandre Brandizzi 2026-08-20 10:58:26 -03:00 • committed by GitHub
commit 8edcb6659d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 2895 additions and 548 deletions

View file

@ -0,0 +1,30 @@
# Work-order `additionalContacts` contract (SH-218)
Authoritative frontend contract for additional POC 2+ contacts. Implemented on
backend PR [shoc-backend#66](https://github.com/Sea-Haven-Industries/shoc-backend/pull/66).
## Shape
```ts
{ name: string; phone: string; notes?: string }
```
- Primary POC remains `pocName` / `pocPhone` / `pocNotes`.
- `additionalContacts` holds ordered POC 2+ only.
- Notes are optional. An empty list (or omitted field) is valid.
- Retained entries require **both** `name` and `phone`. Fully blank placeholder
rows are dropped and must not fail create. Name-only or phone-only entries are
rejected on POST create and PATCH.
## Endpoints
| Operation | Where |
| ------------------ | ------------------------------------------------------------- |
| POST create | `additionalContacts` array on the board create body |
| GET board / search | `additionalContacts` on each board row |
| GET detail | `info.additionalContacts` (same ordered list as board) |
| PATCH | `field: "additionalContacts"`, `value` is a JSON array string |
Until #66 is merged to the deployed API, a missing field is still mapped as `[]`
on the frontend so the UI does not throw. After merge, create → reload →
board/slide-over must round-trip the ordered list through GET detail `info`.

View file

@ -0,0 +1,124 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-week-day-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
return {
id,
woNumber,
workOrderType: 2,
siteCode: woNumber,
lifecycleStatusLabel: "Scheduled",
scheduledDate,
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
};
}
async function mockWorkOrderBoard(page: Page) {
await page.route("**/api/**", (route) => {
const pathname = new URL(route.request().url()).pathname;
if (!pathname.startsWith("/api/")) {
return route.fallback();
}
if (pathname.includes("/workorders/board")) {
return route.fallback();
}
return fulfillJson(route, { data: [] });
});
await page.route("**/api/workorders/board*", (route) => {
if (route.request().url().includes("board/search")) {
return route.fallback();
}
const url = new URL(route.request().url());
const weekStart = url.searchParams.get("weekStart") ?? "2026-08-17";
const scheduled =
weekStart === "2026-08-17"
? [boardRow(1, "MON-WO", "2026-08-17"), boardRow(2, "WED-WO", "2026-08-19")]
: [boardRow(4, "PREV-MON-WO", "2026-08-10")];
return fulfillJson(route, {
weekStart,
weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
counts: { returned: scheduled.length + 1, total: scheduled.length + 1 },
unscheduled: [boardRow(3, "UNSCHED-WO", null)],
scheduled,
});
});
await page.route("**/api/workorders/board/search*", (route) =>
fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
);
}
test.describe("Work Orders week/day navigation (SH-240)", () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript(
(user) => localStorage.setItem("auth", JSON.stringify(user)),
TEST_USER,
);
await mockWorkOrderBoard(page);
});
test("scopes the table to one day and excludes Unscheduled", async ({ page }) => {
await page.goto("/workorders");
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByText("WED-WO").first()).toBeVisible();
await expect(page.getByText("MON-WO")).toHaveCount(0);
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
await expect(page.getByRole("button", { name: /Unscheduled/ })).toHaveCount(0);
await page.getByRole("tab", { name: "Mon · 08/17" }).click();
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByText("MON-WO").first()).toBeVisible();
await expect(page.getByText("WED-WO")).toHaveCount(0);
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
});
test("updates day tabs and selects Monday when the week changes", async ({ page }) => {
await page.goto("/workorders");
await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Week 8/17–8/21" }).click();
await page.getByRole("button", { name: /Aug 10/ }).click();
await expect(page.getByRole("button", { name: "Week 8/10–8/14" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Mon · 08/10" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("tab", { name: "Fri · 08/14" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveCount(0);
await expect(page.getByText("PREV-MON-WO").first()).toBeVisible();
await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
});
});

View file

@ -0,0 +1,25 @@
import { useState } from "react";
import { BrandLockup } from "@/components/common/brand-lockup";
import { cn } from "@/lib/utils";
export type AuthBrandLogoProps = {
className?: string;
};
export function AuthBrandLogo({ className }: AuthBrandLogoProps) {
const [useFallback, setUseFallback] = useState(false);
if (useFallback) {
return <BrandLockup className={className} />;
}
return (
<img
src="/seahaven-logo.svg"
alt="Seahaven"
onError={() => setUseFallback(true)}
className={cn("mx-auto block h-10 w-auto", className)}
/>
);
}

View file

@ -11,14 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) { export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return ( return (
<div className={cn(className)} {...props}> <div className={cn(className)} {...props}>
<Text variant="title" className="m-0 text-center font-bold text-foreground"> <Text
variant="display"
className="m-0 text-center !font-bold ![font-size:var(--text-xl)] !leading-[1.5] text-foreground"
>
{title} {title}
</Text> </Text>
<Text <Text
variant="description" variant="description"
tone="muted" tone="muted"
when={Boolean(subtitle)} when={Boolean(subtitle)}
className="mb-6! mt-1.5! text-center font-normal" className="mb-6! mt-1.5! text-center font-normal ![font-size:var(--text-base)]"
> >
{subtitle} {subtitle}
</Text> </Text>

View file

@ -1,10 +1,10 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header"; import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell"; import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form"; import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema"; import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
@ -27,7 +27,7 @@ export default function LoginPage() {
return ( return (
<AuthPageShell> <AuthPageShell>
<BrandLockup className="mb-8" /> <AuthBrandLogo className="mb-8" />
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" /> <AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
<LoginForm <LoginForm
register={register} register={register}

View file

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

View file

@ -59,18 +59,29 @@ export function SlideOverInfoTabEditView({
pocName={draft.pocName} pocName={draft.pocName}
pocPhone={draft.pocPhone} pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes} pocNotes={draft.pocNotes}
additionalContacts={draft.additionalContacts}
sites={sites} sites={sites}
onSave={(patch) => onSave={(patch) =>
onDraftChange({ onDraftChange({
site: patch.site, site: patch.site,
location: patch.location, location: patch.location,
locationId: patch.locationId, locationId: patch.locationId,
pocName: patch.pocName,
pocPhone: patch.pocPhone,
pocNotes: patch.pocNotes,
...(patch.additionalContacts !== undefined
? { additionalContacts: patch.additionalContacts }
: {}),
}) })
} }
/> />
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO"> <SlideOverInfoRow label="Type of WO">
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} /> <TypeCell
value={draft.type}
severity={draft.severity}
onSave={(type, severity) => onDraftChange({ type, severity })}
/>
</SlideOverInfoRow> </SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To"> <SlideOverInfoRow label="Assigned To">
<AssignedToCell <AssignedToCell

View file

@ -2,20 +2,29 @@ import { Menu, MenuItem } from "@mui/material";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge"; import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order"; import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
isCompletionStatusLabel,
type WoClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import { useMenuAnchor } from "@/hooks/use-menu-anchor"; import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderStatusMenuProps = { type WorkOrderStatusMenuProps = {
status: string; status: string;
disabled?: boolean; disabled?: boolean;
closability: WoClosabilityInput;
onStatusChange: (status: string) => void; onStatusChange: (status: string) => void;
}; };
export function WorkOrderStatusMenu({ export function WorkOrderStatusMenu({
status, status,
disabled = false, disabled = false,
closability,
onStatusChange, onStatusChange,
}: WorkOrderStatusMenuProps) { }: WorkOrderStatusMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor(); const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const closabilityGaps = getClosabilityGaps(closability);
return ( return (
<> <>
@ -29,21 +38,31 @@ export function WorkOrderStatusMenu({
<WorkOrderStatusBadge status={status} /> <WorkOrderStatusBadge status={status} />
</MenuAnchorButton> </MenuAnchorButton>
<Menu anchorEl={anchorEl} open={open} onClose={close}> <Menu anchorEl={anchorEl} open={open} onClose={close}>
{WORK_ORDER_STATUS_OPTIONS.map((option) => ( {WORK_ORDER_STATUS_OPTIONS.map((option) => {
<MenuItem const isCompletionStatus = isCompletionStatusLabel(option);
key={option} const isCompletedBlocked = isCompletionStatus && closabilityGaps.length > 0;
selected={option === status} return (
disabled={disabled} <MenuItem
onClick={() => { key={option}
close(); selected={option === status}
if (option !== status) { disabled={disabled || isCompletedBlocked}
onStatusChange(option); title={
isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined
} }
}} onClick={() => {
> if (isCompletedBlocked) {
{option} return;
</MenuItem> }
))} close();
if (option !== status) {
onStatusChange(option);
}
}}
>
{option}
</MenuItem>
);
})}
</Menu> </Menu>
</> </>
); );

View file

@ -0,0 +1,35 @@
import { NavPillButton } from "@/components/ui/nav-pill-button";
import { formatDayTabLabel, weekdaysMonFri } from "@/domain/work-orders/utils/week-day-nav-utils";
type DayTabsProps = {
weekMonday: string;
selectedDayIso: string;
onChange: (dayIso: string) => void;
disabled?: boolean;
};
export function DayTabs({ weekMonday, selectedDayIso, onChange, disabled = false }: DayTabsProps) {
const days = weekdaysMonFri(weekMonday);
return (
<div className="flex items-center gap-1.5 overflow-x-auto" role="tablist" aria-label="Weekdays">
{days.map((iso) => {
const label = formatDayTabLabel(iso);
const active = iso === selectedDayIso;
return (
<NavPillButton
key={iso}
active={active}
disabled={disabled}
role="tab"
aria-selected={active}
aria-label={label}
onClick={() => onChange(iso)}
>
{label}
</NavPillButton>
);
})}
</div>
);
}

View file

@ -1,133 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
import { NavPillButton } from "@/components/ui/nav-pill-button";
import {
buildWeekWindow,
isFutureWeek,
isLiveWeek,
} from "@/domain/work-orders/utils/list-week-utils";
type WeekNavProps = {
weekMonday: string;
onChange: (mondayIso: string) => void;
disabled?: boolean;
};
const VISIBLE_COUNT = 3;
const WINDOW_BEFORE = 12;
const WINDOW_AFTER = 1;
function WeekLiveBadge() {
return (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Live
</span>
);
}
export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) {
const weeks = useMemo(
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
[weekMonday],
);
const activeIndex = useMemo(
() => weeks.findIndex((week) => week.mondayIso === weekMonday),
[weeks, weekMonday],
);
const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0));
useEffect(() => {
if (activeIndex < 0) return;
const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT);
setOffset((current) => {
if (activeIndex < current) return Math.min(activeIndex, maxOffset);
if (activeIndex > current + VISIBLE_COUNT - 1) {
return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset);
}
return current;
});
}, [activeIndex, weeks.length]);
const visible = weeks.slice(offset, offset + VISIBLE_COUNT);
const canGoBack = offset > 0;
const canGoForward = offset + VISIBLE_COUNT < weeks.length;
return (
<div className="flex shrink-0 items-center gap-1.5">
<NavArrowButton
disabled={disabled || !canGoBack}
onClick={() => setOffset((current) => current - 1)}
aria-label="Previous weeks"
>
<ChevronLeft size={15} />
</NavArrowButton>
{visible.map((week) => {
const isActive = weekMonday === week.mondayIso;
const isDisabled = isFutureWeek(week.mondayIso);
const showLiveBadge = isLiveWeek(week.mondayIso);
return (
<NavPillButton
key={week.mondayIso}
active={isActive}
disabled={disabled || isDisabled}
onClick={() => onChange(week.mondayIso)}
>
{week.label}
{showLiveBadge && <WeekLiveBadge />}
</NavPillButton>
);
})}
<NavArrowButton
disabled={disabled || !canGoForward}
onClick={() => setOffset((current) => current + 1)}
aria-label="Next weeks"
>
<ChevronRight size={15} />
</NavArrowButton>
</div>
);
}

View file

@ -0,0 +1,81 @@
import { Popover } from "@mui/material";
import { ChevronDown } from "lucide-react";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import {
compactWeekLabel,
isFutureWeek,
isLiveWeek,
} from "@/domain/work-orders/utils/list-week-utils";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
import { cn } from "@/lib/utils";
type WeekSelectorProps = {
weekMonday: string;
onChange: (mondayIso: string) => void;
disabled?: boolean;
};
function WeekLiveBadge() {
return (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Live
</span>
);
}
export function WeekSelector({ weekMonday, onChange, disabled = false }: WeekSelectorProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const live = isLiveWeek(weekMonday);
const label = compactWeekLabel(weekMonday);
const handlePick = (mondayIso: string) => {
if (isFutureWeek(mondayIso)) {
return;
}
onChange(mondayIso);
close();
};
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
disabled={disabled}
aria-label={`Week ${label}`}
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-[13px] text-[13px] font-semibold",
"border-[#bfe2f5] bg-(--color-primary-tint) text-primary",
)}
>
{label}
{Boolean(live) && <WeekLiveBadge />}
<ChevronDown size={14} aria-hidden />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
>
<div className="p-3">
<WeekPicker selected={weekMonday} onPick={handlePick} isWeekDisabled={isFutureWeek} />
</div>
</Popover>
</>
);
}

View file

@ -1,12 +1,16 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters"; import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter"; import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav"; import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment"; import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
import { Text } from "@/components/ui/text";
type WorkOrdersFilterBarProps = { type WorkOrdersFilterBarProps = {
weekMonday: string; weekMonday: string;
onWeekChange: (mondayIso: string) => void; onWeekChange: (mondayIso: string) => void;
selectedDayIso: string;
onSelectedDayChange: (dayIso: string) => void;
dispatcherIds: Set<string>; dispatcherIds: Set<string>;
onDispatcherChange: (ids: Set<string>) => void; onDispatcherChange: (ids: Set<string>) => void;
typeFilter: WorkOrderTypeFilter; typeFilter: WorkOrderTypeFilter;
@ -22,6 +26,8 @@ type WorkOrdersFilterBarProps = {
export function WorkOrdersFilterBar({ export function WorkOrdersFilterBar({
weekMonday, weekMonday,
onWeekChange, onWeekChange,
selectedDayIso,
onSelectedDayChange,
dispatcherIds, dispatcherIds,
onDispatcherChange, onDispatcherChange,
typeFilter, typeFilter,
@ -37,21 +43,35 @@ export function WorkOrdersFilterBar({
return ( return (
<div <div
className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4" className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
aria-disabled={disabled} aria-disabled={disabled}
> >
<DispatcherFilter <div className="flex items-center gap-3 px-4 pt-2">
selectedIds={dispatcherIds} <DispatcherFilter
onChange={onDispatcherChange} selectedIds={dispatcherIds}
users={users} onChange={onDispatcherChange}
usersLoading={usersLoading} users={users}
currentUserId={currentUserId} usersLoading={usersLoading}
disabled={disabled} currentUserId={currentUserId}
/> disabled={disabled}
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} /> />
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} /> <div className="flex-1" />
<div className="flex-1" /> <Text variant="caption" tone="muted" className="shrink-0">
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span> {resultLabel}
</Text>
</div>
<div className="flex flex-col gap-1 px-4 pt-1" aria-label="Week and day">
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
<DayTabs
weekMonday={weekMonday}
selectedDayIso={selectedDayIso}
onChange={onSelectedDayChange}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 px-4 pt-1 pb-2">
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
</div>
</div> </div>
); );
} }

View file

@ -39,6 +39,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName, pocName: draft.pocName,
pocPhone: draft.pocPhone, pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes, pocNotes: draft.pocNotes,
additionalContacts: draft.additionalContacts ?? [],
scheduleWeekOnly, scheduleWeekOnly,
targetWeek: draft.targetWeek || "", targetWeek: draft.targetWeek || "",
isAddOnOverride: draft.isAddOnOverride, isAddOnOverride: draft.isAddOnOverride,

View file

@ -0,0 +1,42 @@
import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
type BuildSiteDialogPatchArgs = {
code: string;
locId: string;
selected: LocationOption | undefined;
pocName: string;
pocPhone: string;
pocNotes: string;
extraContacts: WorkOrderAdditionalContact[];
contactsDirty: boolean;
baselineHadContacts: boolean;
};
export function buildSiteDialogPatch({
code,
locId,
selected,
pocName,
pocPhone,
pocNotes,
extraContacts,
contactsDirty,
baselineHadContacts,
}: BuildSiteDialogPatchArgs): SitePatch {
const patch: SitePatch = {
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName,
pocPhone,
pocNotes,
};
if (contactsDirty || baselineHadContacts) {
patch.additionalContacts = extraContacts;
}
return patch;
}

View file

@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
import { formatSitePlace } from "@/domain/locations/mappers/location-mapper"; import { formatSitePlace } from "@/domain/locations/mappers/location-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { HighlightText } from "./highlight-text"; import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron"; import { HoverChevron } from "./hover-chevron";
@ -15,6 +16,7 @@ type SiteCellProps = {
pocName?: string; pocName?: string;
pocPhone?: string; pocPhone?: string;
pocNotes?: string; pocNotes?: string;
additionalContacts?: WorkOrderAdditionalContact[];
sites: LocationOption[]; sites: LocationOption[];
q?: string; q?: string;
/** Inline create: editable POC + Site/POC required to confirm the modal. */ /** Inline create: editable POC + Site/POC required to confirm the modal. */
@ -31,6 +33,7 @@ export function SiteCell({
pocName, pocName,
pocPhone, pocPhone,
pocNotes, pocNotes,
additionalContacts,
sites, sites,
q, q,
createMode = false, createMode = false,
@ -93,6 +96,7 @@ export function SiteCell({
pocName={pocName} pocName={pocName}
pocPhone={pocPhone} pocPhone={pocPhone}
pocNotes={pocNotes} pocNotes={pocNotes}
additionalContacts={additionalContacts}
sites={sites} sites={sites}
createMode={createMode} createMode={createMode}
onSave={onSave} onSave={onSave}

View file

@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button";
type SiteDialogFooterProps = {
onClose: () => void;
onConfirm: () => void;
};
export function SiteDialogFooter({ onClose, onConfirm }: SiteDialogFooterProps) {
return (
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
Cancel
</Button>
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onConfirm}>
Save
</Button>
</div>
);
}

View file

@ -1,87 +1 @@
import { export { PocContactFields as SiteDialogPocFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Text } from "@/components/ui/text";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
type SiteDialogPocFieldsProps = {
pocName: string;
pocPhone: string;
pocNotes: string;
editable?: boolean;
showErrors?: boolean;
onPocNameChange?: (value: string) => void;
onPocPhoneChange?: (value: string) => void;
onPocNotesChange?: (value: string) => void;
};
export function SiteDialogPocFields({
pocName,
pocPhone,
pocNotes,
editable = false,
showErrors = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
}: SiteDialogPocFieldsProps) {
const nameMissing = showErrors && !pocName.trim();
const phoneMissing = showErrors && !pocPhone.trim();
return (
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
{editable
? "Auto-filled from records — edit if outdated"
: "Shown from site/work order records. Board edit saves site code only."}
</Text>
<div className="space-y-2">
<div>
<input
value={pocName}
readOnly={!editable}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: nameMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocNameChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
Contact name is required
</Text>
</div>
<div>
<input
value={pocPhone}
readOnly={!editable}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: phoneMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocPhoneChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
Contact phone is required
</Text>
</div>
<textarea
value={pocNotes}
readOnly={!editable}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
onChange={(e) => onPocNotesChange?.(e.target.value)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
type SiteDialogPocSectionProps = {
pocName: string;
pocPhone: string;
pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[];
showPrimaryErrors?: boolean;
onPocNameChange: (value: string) => void;
onPocPhoneChange: (value: string) => void;
onPocNotesChange: (value: string) => void;
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
};
export function SiteDialogPocSection({
pocName,
pocPhone,
pocNotes,
additionalContacts,
showPrimaryErrors = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
onAdditionalContactsChange,
}: SiteDialogPocSectionProps) {
return (
<PocContactFields
pocName={pocName}
pocPhone={pocPhone}
pocNotes={pocNotes}
additionalContacts={additionalContacts}
showPrimaryErrors={showPrimaryErrors}
onPocNameChange={onPocNameChange}
onPocPhoneChange={onPocPhoneChange}
onPocNotesChange={onPocNotesChange}
onAdditionalContactsChange={onAdditionalContactsChange}
helperText="Auto-filled from site records when empty. Additional contacts persist on save."
/>
);
}

View file

@ -0,0 +1,12 @@
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
/** Omitted for site-only saves when GET had no contacts and the user did not edit them. */
additionalContacts?: WorkOrderAdditionalContact[];
};

View file

@ -2,17 +2,16 @@ import type { MouseEvent } from "react";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel"; import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields"; import { SiteDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer";
import { import { SiteDialogPocSection } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section";
useSiteDialogState, import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
type SitePatch, import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
export type { SitePatch }; export type { SitePatch };
@ -24,6 +23,7 @@ type SiteDialogProps = {
pocName?: string; pocName?: string;
pocPhone?: string; pocPhone?: string;
pocNotes?: string; pocNotes?: string;
additionalContacts?: WorkOrderAdditionalContact[];
sites: LocationOption[]; sites: LocationOption[];
createMode?: boolean; createMode?: boolean;
onSave: (patch: SitePatch) => void; onSave: (patch: SitePatch) => void;
@ -37,6 +37,7 @@ export function SiteDialog({
pocName = "", pocName = "",
pocPhone = "", pocPhone = "",
pocNotes = "", pocNotes = "",
additionalContacts = [],
sites, sites,
createMode = false, createMode = false,
onSave, onSave,
@ -49,6 +50,7 @@ export function SiteDialog({
pocName, pocName,
pocPhone, pocPhone,
pocNotes, pocNotes,
additionalContacts,
sites, sites,
createMode, createMode,
onSave, onSave,
@ -116,26 +118,20 @@ export function SiteDialog({
/> />
)} )}
<SiteDialogPocFields <SiteDialogPocSection
pocName={s.pn} pocName={s.pn}
pocPhone={s.pp} pocPhone={s.pp}
pocNotes={s.notes} pocNotes={s.notes}
editable={createMode} additionalContacts={s.extraContacts}
showErrors={createMode && s.showErrors} showPrimaryErrors={createMode && s.showErrors}
onPocNameChange={s.setPn} onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp} onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes} onPocNotesChange={s.setNotes}
onAdditionalContactsChange={s.setExtraContacts}
/> />
</div> </div>
<div className="mt-4 flex items-center justify-end gap-2"> <SiteDialogFooter onClose={s.close} onConfirm={s.attemptSave} />
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
Cancel
</Button>
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
Save
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -1,21 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { LocationOption } from "@/domain/work-orders/types/work-order";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
export type SitePatch = { export type { SitePatch };
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type UseSiteDialogStateArgs = { type UseSiteDialogStateArgs = {
open: boolean; open: boolean;
@ -25,6 +18,7 @@ type UseSiteDialogStateArgs = {
pocName: string; pocName: string;
pocPhone: string; pocPhone: string;
pocNotes: string; pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[]; sites: LocationOption[];
createMode: boolean; createMode: boolean;
onSave: (patch: SitePatch) => void; onSave: (patch: SitePatch) => void;
@ -38,6 +32,7 @@ export function useSiteDialogState({
pocName, pocName,
pocPhone, pocPhone,
pocNotes, pocNotes,
additionalContacts = [],
sites, sites,
createMode, createMode,
onSave, onSave,
@ -47,7 +42,13 @@ export function useSiteDialogState({
const [pn, setPn] = useState(pocName); const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone); const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes); const [notes, setNotes] = useState(pocNotes);
const [extraContacts, setExtraContacts] = useState(additionalContacts);
const [pocFilledFor, setPocFilledFor] = useState(""); const [pocFilledFor, setPocFilledFor] = useState("");
const [dirty, setDirty] = useState(false);
const [contactsDirty, setContactsDirty] = useState(false);
const [baselineHadContacts, setBaselineHadContacts] = useState(
() => additionalContacts.length > 0,
);
const [showErrors, setShowErrors] = useState(false); const [showErrors, setShowErrors] = useState(false);
const { const {
@ -64,6 +65,14 @@ export function useSiteDialogState({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setDirty(false);
setContactsDirty(false);
setShowErrors(false);
}
}, [open]);
useEffect(() => {
if (!open || dirty) {
return; return;
} }
const resolved = resolveLocationId(locationId, value, sites); const resolved = resolveLocationId(locationId, value, sites);
@ -72,15 +81,15 @@ export function useSiteDialogState({
setPn(pocName); setPn(pocName);
setPp(pocPhone); setPp(pocPhone);
setNotes(pocNotes); setNotes(pocNotes);
setShowErrors(false); setExtraContacts(additionalContacts);
setBaselineHadContacts(additionalContacts.length > 0);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : ""); setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]); }, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
useEffect(() => { useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) { if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return; return;
} }
// Only autofill empty fields so typed createMode POC edits are not overwritten.
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? ""))); setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? ""))); setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId); setPocFilledFor(locId);
@ -94,49 +103,72 @@ export function useSiteDialogState({
setCode(resolveSiteCode(next)); setCode(resolveSiteCode(next));
} }
if (siteChanged) { if (siteChanged) {
setPn(""); setExtraContacts([]);
setPp(""); setContactsDirty(true);
setDirty(true);
if (createMode) {
setPn("");
setPp("");
}
setPocFilledFor(""); setPocFilledFor("");
} else if (id !== pocFilledFor) { } else if (id !== pocFilledFor) {
setPocFilledFor(""); setPocFilledFor("");
} }
}; };
const buildPatch = (): SitePatch =>
buildSiteDialogPatch({
code,
locId,
selected,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
extraContacts,
contactsDirty,
baselineHadContacts,
});
const markPocDirty = (apply: () => void) => {
setDirty(true);
apply();
};
const markContactsDirty = (contacts: WorkOrderAdditionalContact[]) => {
setDirty(true);
setContactsDirty(true);
setExtraContacts(contacts);
};
const attemptSave = () => { const attemptSave = () => {
if (createMode && !canConfirm) { if (createMode && !canConfirm) {
setShowErrors(true); setShowErrors(true);
return; return;
} }
onSave({ onSave(buildPatch());
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false); onOpenChange(false);
}; };
return { return {
locId, locId,
pn, code,
pp, selected,
notes,
showErrors,
locationDetail, locationDetail,
locationDetailLoading, locationDetailLoading,
locationDetailError, locationDetailError,
selected,
addressPreview, addressPreview,
pn,
pp,
notes,
extraContacts,
showErrors,
siteMissing, siteMissing,
canConfirm,
code,
handlePick, handlePick,
attemptSave, attemptSave,
setPn, setPn: (next: string) => markPocDirty(() => setPn(next)),
setPp, setPp: (next: string) => markPocDirty(() => setPp(next)),
setNotes, setNotes: (next: string) => markPocDirty(() => setNotes(next)),
setExtraContacts: markContactsDirty,
close: () => onOpenChange(false), close: () => onOpenChange(false),
}; };
} }

View file

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

View file

@ -5,7 +5,6 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week"; import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify"; import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
import type { WoTableRowHandlers } from "./wo-table-row"; import type { WoTableRowHandlers } from "./wo-table-row";
@ -13,7 +12,8 @@ import type { WoTableRowHandlers } from "./wo-table-row";
type UseWoTableRowHandlersOptions = { type UseWoTableRowHandlersOptions = {
tableData: ReturnTypeOfUseWorkOrdersTableData; tableData: ReturnTypeOfUseWorkOrdersTableData;
weekMonday: string; weekMonday: string;
onWeekChange: (mondayIso: string) => void; onNavigateToDay: (dateIso: string) => void;
onOpenAdvancedFilters?: () => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenUplifts?: (row: WorkOrderTableRow) => void;
@ -26,7 +26,8 @@ type UseWoTableRowHandlersOptions = {
export function useWoTableRowHandlers({ export function useWoTableRowHandlers({
tableData, tableData,
weekMonday, weekMonday,
onWeekChange, onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver, onOpenSlideOver,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
@ -35,14 +36,7 @@ export function useWoTableRowHandlers({
onScheduleHighlight, onScheduleHighlight,
onDuplicateFound, onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers { }: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const { const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData;
dragState: dragStateRef,
setDragViz,
reorderRows,
endDrag,
setUnschedOpen,
triggerFlash,
} = tableData;
return useMemo( return useMemo(
() => ({ () => ({
@ -80,14 +74,16 @@ export function useWoTableRowHandlers({
onScheduleDate: (id: string | number, date: string) => { onScheduleDate: (id: string | number, date: string) => {
onPatch(id, applySchedulePatch(date, false)); onPatch(id, applySchedulePatch(date, false));
if (shouldNotifyCrossWeek(date, weekMonday)) { if (shouldNotifyCrossWeek(date, weekMonday)) {
const targetMonday = mondayOf(date);
rescheduleNotify(date, { rescheduleNotify(date, {
onNavigate: () => { onNavigate: () => {
onWeekChange(targetMonday); onNavigateToDay(date);
setTimeout(() => triggerFlash(id), 180); setTimeout(() => triggerFlash(id), 180);
}, },
}); });
return;
} }
onNavigateToDay(date);
triggerFlash(id);
}, },
onScheduleWeek: (id: string | number, week: string) => { onScheduleWeek: (id: string | number, week: string) => {
onPatch(id, { onPatch(id, {
@ -97,12 +93,10 @@ export function useWoTableRowHandlers({
dayGroup: "", dayGroup: "",
dayLabel: "", dayLabel: "",
}); });
setUnschedOpen(true);
triggerFlash(id);
weekMoveNotify(week, { weekMoveNotify(week, {
onNavigate: () => { onNavigate: () => {
setUnschedOpen(true); onNavigateToDay(week);
setTimeout(() => triggerFlash(id), 180); onOpenAdvancedFilters?.();
}, },
}); });
}, },
@ -138,10 +132,10 @@ export function useWoTableRowHandlers({
setDragViz, setDragViz,
reorderRows, reorderRows,
endDrag, endDrag,
setUnschedOpen,
triggerFlash, triggerFlash,
weekMonday, weekMonday,
onWeekChange, onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver, onOpenSlideOver,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,

View file

@ -2,7 +2,6 @@ import { Fragment, type ReactNode } from "react";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { import {
UNASSIGNED_DAY_KEY, UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
type WorkOrderTableRow, type WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
@ -21,11 +20,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
} }
const unassignedRows = tableData.unassigned ?? []; const unassignedRows = tableData.unassigned ?? [];
const showUnscheduled =
(tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
// SH-165: "still loading" and "zero unassigned" are distinct states. Without
// this split the pin announced "No unassigned work orders." while the queue
// was still paging board/search on first mount.
const showUnassignedLoading = const showUnassignedLoading =
tableData.unassignedOpen && tableData.unassignedOpen &&
unassignedRows.length === 0 && unassignedRows.length === 0 &&
@ -79,24 +73,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
{unassignedRows.map((row) => renderRow(row))} {unassignedRows.map((row) => renderRow(row))}
</> </>
)} )}
{showUnscheduled && (
<>
<WoTableSectionHeader
variant="unscheduled"
label="Unscheduled"
count={tableData.unscheduled.length}
collapsed={!tableData.unschedOpen}
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
/>
{tableData.unschedOpen && (
<>
{renderInlineRow(UNSCHEDULED_DAY_KEY)}
{tableData.unscheduled.map((row) => renderRow(row, true))}
</>
)}
</>
)}
{tableData.displayGroups.map((group) => ( {tableData.displayGroups.map((group) => (
<Fragment key={group.key}> <Fragment key={group.key}>
<WoTableSectionHeader <WoTableSectionHeader

View file

@ -55,6 +55,7 @@ export function WoTableInlineIdentityCells({
pocName={draft.pocName} pocName={draft.pocName}
pocPhone={draft.pocPhone} pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes} pocNotes={draft.pocNotes}
additionalContacts={draft.additionalContacts}
sites={sites} sites={sites}
createMode createMode
onSave={(p) => onSave={(p) =>
@ -65,6 +66,9 @@ export function WoTableInlineIdentityCells({
pocName: p.pocName, pocName: p.pocName,
pocPhone: p.pocPhone, pocPhone: p.pocPhone,
pocNotes: p.pocNotes, pocNotes: p.pocNotes,
...(p.additionalContacts !== undefined
? { additionalContacts: p.additionalContacts }
: {}),
}) })
} }
/> />

View file

@ -160,7 +160,6 @@ export function WoTableInlineRowCells({
}} }}
/> />
</td> </td>
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} /> <InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
@ -170,7 +169,6 @@ export function WoTableInlineRowCells({
onSave={(date) => onPatch({ dueDate: date })} onSave={(date) => onPatch({ dueDate: date })}
/> />
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<PMTypeCell <PMTypeCell
value={draft.pm} value={draft.pm}
@ -188,7 +186,6 @@ export function WoTableInlineRowCells({
} }
/> />
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<VendorCell <VendorCell
vendorId={draft.vendorId} vendorId={draft.vendorId}
@ -206,7 +203,6 @@ export function WoTableInlineRowCells({
} }
/> />
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<ApptTimeCell <ApptTimeCell
apptTime={ apptTime={
@ -218,7 +214,6 @@ export function WoTableInlineRowCells({
}} }}
/> />
</td> </td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<StatusCell <StatusCell
wo={draftRow as never} wo={draftRow as never}

View file

@ -52,6 +52,7 @@ export function WoTableRowCells({
pocName={row.pocName} pocName={row.pocName}
pocPhone={row.pocPhone} pocPhone={row.pocPhone}
pocNotes={row.pocNotes} pocNotes={row.pocNotes}
additionalContacts={row.additionalContacts}
sites={sites} sites={sites}
q={search} q={search}
onSave={(p) => onSave={(p) =>
@ -62,6 +63,9 @@ export function WoTableRowCells({
pocName: p.pocName, pocName: p.pocName,
pocPhone: p.pocPhone, pocPhone: p.pocPhone,
pocNotes: p.pocNotes, pocNotes: p.pocNotes,
...(p.additionalContacts !== undefined
? { additionalContacts: p.additionalContacts }
: {}),
}) })
} }
/> />

View file

@ -9,6 +9,7 @@ import type {
WorkOrderTablePatch, WorkOrderTablePatch,
WorkOrderTableRow, WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row"; } 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 { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
@ -29,7 +30,7 @@ type WoTableProps = {
vendors: VendorDropdownItem[]; vendors: VendorDropdownItem[];
pmTypes: string[]; pmTypes: string[];
weekMonday: string; weekMonday: string;
onWeekChange: (mondayIso: string) => void; onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */ /** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void;
@ -40,6 +41,34 @@ type WoTableProps = {
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
}; };
type WoTableEmptyShellProps = {
message: string;
actionLabel: string;
onAction?: () => void;
};
function WoTableLoading({
weekMonday,
selectedDayIso,
}: {
weekMonday: string;
selectedDayIso?: string;
}) {
return (
<WoTableShell>
<WoTableSkeleton weekMonday={weekMonday} selectedDayIso={selectedDayIso} />
</WoTableShell>
);
}
function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShellProps) {
return (
<WoTableShell>
<WoTableEmptyState message={message} actionLabel={actionLabel} onAction={onAction} />
</WoTableShell>
);
}
export function WoTable({ export function WoTable({
tableData, tableData,
users, users,
@ -47,7 +76,7 @@ export function WoTable({
vendors, vendors,
pmTypes, pmTypes,
weekMonday, weekMonday,
onWeekChange, onNavigateToDay,
onOpenSlideOver, onOpenSlideOver,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
@ -70,7 +99,8 @@ export function WoTable({
const handlers = useWoTableRowHandlers({ const handlers = useWoTableRowHandlers({
tableData, tableData,
weekMonday, weekMonday,
onWeekChange, onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver, onOpenSlideOver,
onOpenCompDoc, onOpenCompDoc,
onOpenUplifts, onOpenUplifts,
@ -125,9 +155,7 @@ export function WoTable({
if (tableData.isLoading) { if (tableData.isLoading) {
return ( return (
<WoTableShell> <WoTableLoading weekMonday={weekMonday} selectedDayIso={tableData.displayGroups[0]?.date} />
<WoTableSkeleton weekMonday={weekMonday} />
</WoTableShell>
); );
} }
@ -135,25 +163,26 @@ export function WoTable({
if (emptyAdv) { if (emptyAdv) {
return ( return (
<WoTableShell> <WoTableEmptyShell
<WoTableEmptyState message="No work orders match these filters"
message="No work orders match these filters" actionLabel="Adjust filters"
actionLabel="Adjust filters" onAction={onOpenAdvancedFilters}
onAction={onOpenAdvancedFilters} />
/>
</WoTableShell>
); );
} }
if (!tableData.advActive && tableData.noSearchResults) { const showSearchEmpty =
!tableData.advActive &&
tableData.noSearchResults &&
isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
if (showSearchEmpty) {
return ( return (
<WoTableShell> <WoTableEmptyShell
<WoTableEmptyState message="No work orders match your search"
message="No work orders match your search" actionLabel="Clear search"
actionLabel="Clear search" onAction={onClearSearch}
onAction={onClearSearch} />
/>
</WoTableShell>
); );
} }

View file

@ -6,6 +6,16 @@ import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays"; import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard"; 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 { 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 = { type WorkOrdersListPagePanelsProps = {
page: ReturnType<typeof useWorkOrdersListPage>; page: ReturnType<typeof useWorkOrdersListPage>;
@ -46,7 +56,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
pmTypes={pmTypes} pmTypes={pmTypes}
allOrders={tableData.rows} allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)} onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onCancelWO={page.setConfirmCancel} onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending} saving={page.isPatchPending}

View file

@ -1,7 +1,7 @@
import { Skeleton } from "@mui/material"; import { Skeleton } from "@mui/material";
import { LoadingRegion } from "@/components/ui/loading-region"; import { LoadingRegion } from "@/components/ui/loading-region";
import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows"; 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 { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
import { import {
WO_TABLE_COLS, WO_TABLE_COLS,
@ -11,13 +11,26 @@ import {
type WoTableSkeletonProps = { type WoTableSkeletonProps = {
weekMonday: string; weekMonday: string;
selectedDayIso?: string;
rowsPerDay?: number; rowsPerDay?: number;
}; };
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w })); const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) { function skeletonDayIso(weekMonday: string, selectedDayIso?: string): string {
const days = buildWeekSkeleton(weekMonday); 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 ( return (
<LoadingRegion label="Loading work orders" className="h-full"> <LoadingRegion label="Loading work orders" className="h-full">
@ -30,9 +43,8 @@ export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonP
> >
<WoTableHead /> <WoTableHead />
<tbody> <tbody>
{days.map((day) => ( <DaySkeletonSection label="Unassigned" rows={1} />
<DaySkeletonSection key={day.key} label={day.label} rows={rowsPerDay} /> <DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
))}
</tbody> </tbody>
</table> </table>
</LoadingRegion> </LoadingRegion>

View file

@ -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>,
): WorkOrderAdditionalContact[] {
return contacts.map((contact, contactIndex) =>
contactIndex === index ? { ...contact, ...patch } : contact,
);
}
function AdditionalPocCard({
contact,
index,
onChange,
onRemove,
}: {
contact: WorkOrderAdditionalContact;
index: number;
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
onRemove: () => void;
}) {
const nameMissing = extraNameMissing(contact);
const phoneMissing = extraPhoneMissing(contact);
return (
<div
className="mt-3 space-y-2 rounded-lg p-3"
style={{ border: "1px solid var(--color-border)", background: "var(--color-bg-muted)" }}
>
<div className="flex items-center justify-between gap-2">
<Text
as="p"
className="text-[11px] font-semibold uppercase tracking-wide"
style={{ color: "var(--color-text-muted)" }}
>
Point of contact {index + 2}
</Text>
<button
type="button"
aria-label={`Remove point of contact ${index + 2}`}
className="inline-flex shrink-0 items-center justify-center rounded p-1 transition-colors hover:bg-[var(--color-bg-surface)]"
style={{ color: "var(--color-text-muted)" }}
onClick={onRemove}
>
<Trash2 size={14} />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<input
value={contact.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
borderColor: nameMissing ? "var(--destructive)" : undefined,
}}
/>
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
Contact name is required
</Text>
</div>
<div>
<input
value={contact.phone}
onChange={(event) => onChange({ phone: event.target.value })}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
borderColor: phoneMissing ? "var(--destructive)" : undefined,
}}
/>
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
Contact phone is required
</Text>
</div>
</div>
<textarea
value={contact.notes ?? ""}
onChange={(event) => onChange({ notes: event.target.value })}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
);
}
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 (
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
{Boolean(helperText) && (
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
{helperText}
</Text>
)}
<div className="space-y-2">
<div>
<input
value={pocName}
onChange={(event) => onPocNameChange(event.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
borderColor: nameMissing ? "var(--destructive)" : undefined,
}}
/>
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
Contact name is required
</Text>
</div>
<div>
<input
value={pocPhone}
onChange={(event) => onPocPhoneChange(event.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
borderColor: phoneMissing ? "var(--destructive)" : undefined,
}}
/>
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
Contact phone is required
</Text>
</div>
<textarea
value={pocNotes}
onChange={(event) => onPocNotesChange(event.target.value)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
{additionalContacts.map((contact, index) => (
<AdditionalPocCard
key={`additional-poc-${index}`}
contact={contact}
index={index}
onChange={(patch) =>
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
}
onRemove={() =>
onAdditionalContactsChange(
additionalContacts.filter((_, contactIndex) => contactIndex !== index),
)
}
/>
))}
<Button
type="button"
variant="ghost"
className="mt-3 min-h-0 gap-1.5 px-0 py-1 text-[12px]"
style={{ color: "var(--color-primary)" }}
onClick={handleAddContact}
>
<Plus size={14} className="shrink-0" />
Add point of contact
</Button>
</div>
);
}

View file

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

View file

@ -11,9 +11,10 @@ import {
type WeekPickerProps = { type WeekPickerProps = {
selected: string; selected: string;
onPick: (mondayIso: string) => void; 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 init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
const [iy, im] = init.split("-").map(Number); const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy); const [viewY, setViewY] = useState(iy);
@ -66,12 +67,14 @@ export function WeekPicker({ selected, onPick }: WeekPickerProps) {
{weeks.map((mon) => { {weeks.map((mon) => {
const isSel = Boolean(selected) && selected === mon; const isSel = Boolean(selected) && selected === mon;
const isThis = mon === thisMonday; const isThis = mon === thisMonday;
const isDisabled = Boolean(isWeekDisabled?.(mon));
return ( return (
<button <button
key={mon} key={mon}
type="button" type="button"
disabled={isDisabled}
onClick={() => onPick(mon)} onClick={() => onPick(mon)}
className="flex items-center transition-colors" className="flex items-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
style={{ style={{
height: 36, height: 36,
borderRadius: 6, borderRadius: 6,

View file

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

View file

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

View file

@ -26,6 +26,18 @@ function pocLabel(draft: WorkOrderWizardDraft): string {
return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName; 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. */ /** Builds the label/value rows for the wizard sidebar's live summary card. */
export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] { export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] {
return [ return [
@ -36,6 +48,7 @@ export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [str
["Assigned to", draft.assigneeName || "—"], ["Assigned to", draft.assigneeName || "—"],
["Site", draft.siteCode || draft.locationName || "—"], ["Site", draft.siteCode || draft.locationName || "—"],
["POC", pocLabel(draft)], ["POC", pocLabel(draft)],
["Additional POCs", formatAdditionalPocSummary(draft)],
["Service", draft.pm || "—"], ["Service", draft.pm || "—"],
["Technician", draft.vendorName || "—"], ["Technician", draft.vendorName || "—"],
["Company", draft.vendorName || "—"], ["Company", draft.vendorName || "—"],

View file

@ -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 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 = { type WizardStepLocationPocFieldsProps = {
draft: WorkOrderWizardDraft; draft: WorkOrderWizardDraft;
@ -20,44 +15,17 @@ export function WizardStepLocationPocFields({
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim(); const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return ( return (
<div> <PocContactFields
<WizLabel required>Point of contact</WizLabel> pocName={draft.pocName}
<Text pocPhone={draft.pocPhone}
as="p" pocNotes={draft.pocNotes}
variant="description" additionalContacts={draft.additionalContacts}
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }} onPocNameChange={(value) => onUpdate("pocName", value)}
> onPocPhoneChange={(value) => onUpdate("pocPhone", value)}
Auto-filled from records — edit if outdated onPocNotesChange={(value) => onUpdate("pocNotes", value)}
</Text> onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
<div className="grid grid-cols-2 gap-2"> helperText="Auto-filled from records — edit if outdated"
<input showPrimaryErrors={showErrors && pocIncomplete}
value={draft.pocName} />
onChange={(e) => onUpdate("pocName", e.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={draft.pocPhone}
onChange={(e) => onUpdate("pocPhone", e.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(showErrors && pocIncomplete) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
POC name and phone are required.
</Text>
)}
<textarea
value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
); );
} }

View file

@ -42,6 +42,7 @@ export function WizardStepLocationSiteSelect({
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
}); });
}} }}
/> />

View file

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

View file

@ -1,7 +1,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types"; 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 { 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 { import {
useAddWorkOrderComment, useAddWorkOrderComment,
useChangeWorkOrderAssignment, useChangeWorkOrderAssignment,
@ -11,9 +14,14 @@ import {
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions"; 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) { 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 { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId); const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
@ -28,6 +36,11 @@ export function useWorkOrderViewPage(workOrderId: string) {
const [vendorEditorLoading, setVendorEditorLoading] = useState(false); const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState(""); const [vendorEditorError, setVendorEditorError] = useState("");
const wo = useMemo(
() => (detail ? enrichDetailClosabilityFromBoard(detail, boardDetail) : undefined),
[detail, boardDetail],
);
const customerComments = useMemo( const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"), () => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments], [wo?.comments],
@ -119,6 +132,7 @@ export function useWorkOrderViewPage(workOrderId: string) {
return { return {
wo, wo,
isLoading, isLoading,
isClosabilityReady,
error, error,
refetch, refetch,
users, users,

View file

@ -17,6 +17,12 @@ import {
toBoardQueryParams, toBoardQueryParams,
} from "@/domain/work-orders/utils/board-query-params"; } from "@/domain/work-orders/utils/board-query-params";
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; 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() { export function useWorkOrdersListFilters() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -27,6 +33,9 @@ export function useWorkOrdersListFilters() {
const [sortBy, setSortBy] = useState(""); const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">(""); const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday); const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
const [selectedDayIso, setSelectedDayIsoState] = useState(() =>
initialSelectedDay(currentWeekMonday()),
);
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set()); const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All"); const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
@ -42,6 +51,7 @@ export function useWorkOrdersListFilters() {
); );
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]); const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
const weekDays = useMemo(() => weekdaysMonFri(weekMonday), [weekMonday]);
const boardParams = useMemo<WorkOrderBoardQueryParams>( const boardParams = useMemo<WorkOrderBoardQueryParams>(
() => toBoardQueryParams(barFilters, debouncedSearch), () => toBoardQueryParams(barFilters, debouncedSearch),
@ -55,6 +65,18 @@ export function useWorkOrdersListFilters() {
const setWeekMonday = (value: string) => { const setWeekMonday = (value: string) => {
setWeekMondayState(value); 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); setPage(0);
}; };
@ -123,6 +145,8 @@ export function useWorkOrdersListFilters() {
sortBy, sortBy,
sortDir, sortDir,
weekMonday, weekMonday,
selectedDayIso,
weekDays,
dispatcherIds, dispatcherIds,
typeFilter, typeFilter,
barFilters, barFilters,
@ -139,6 +163,8 @@ export function useWorkOrdersListFilters() {
setPage, setPage,
setPageSize, setPageSize,
setWeekMonday, setWeekMonday,
setSelectedDayIso,
navigateToDay,
setDispatcherIds, setDispatcherIds,
setTypeFilter, setTypeFilter,
setSortBy, setSortBy,

View file

@ -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 { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { 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 { import {
buildDayGroups,
composeBoardSections, composeBoardSections,
filterByBarAndSearch, filterByBarAndSearch,
mergeWeeklyAndUnassignedQueue, mergeWeeklyAndUnassignedQueue,
@ -74,19 +78,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
}); });
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]); }, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); const dayGroups = useMemo(
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); () =>
advActive
? []
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
);
const searchActive = filters.search.trim() !== ""; const searchActive = filters.search.trim() !== "";
const visibleRows = advActive ? advResults : filtered; const displayGroups = dayGroups;
const noSearchResults = visibleRows.length === 0 && searchActive; const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
const isEmptyWeek = const noSearchResults =
!advActive && !advActive &&
scheduled.length === 0 && isDayScopedSearchEmpty({
unassigned.length === 0 && searchActive,
unscheduled.length === 0 && unassignedCount: unassigned.length,
!searchActive; dayItemCount: dayVisibleCount,
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups; unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
});
const isEmptyWeek =
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
const todayKey = const todayKey =
filters.weekMonday === currentWeekMonday() filters.weekMonday === currentWeekMonday()
@ -138,9 +150,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
cancelDraft: local.cancelDraft, cancelDraft: local.cancelDraft,
addRow: local.addRow, addRow: local.addRow,
scopeTotalCount: data?.totalCount ?? 0, scopeTotalCount: data?.totalCount ?? 0,
totalCount: advActive totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
? (data?.totalCount ?? advResults.length)
: (data?.returnedCount ?? weeklyItems.length),
search: filters.search, search: filters.search,
}; };
} }

View file

@ -7,8 +7,18 @@ import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/l
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels"; import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; 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"; 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() { export default function WorkOrdersListPage() {
const { user } = useAuthContext(); const { user } = useAuthContext();
const page = useWorkOrdersListPage(); const page = useWorkOrdersListPage();
@ -35,6 +45,8 @@ export default function WorkOrdersListPage() {
<WorkOrdersFilterBar <WorkOrdersFilterBar
weekMonday={filters.weekMonday} weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday} onWeekChange={filters.setWeekMonday}
selectedDayIso={filters.selectedDayIso}
onSelectedDayChange={filters.setSelectedDayIso}
dispatcherIds={filters.dispatcherIds} dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds} onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter} typeFilter={filters.typeFilter}
@ -78,9 +90,9 @@ export default function WorkOrdersListPage() {
vendors={page.vendors} vendors={page.vendors}
pmTypes={page.pmTypes} pmTypes={page.pmTypes}
weekMonday={filters.weekMonday} weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday} onNavigateToDay={filters.navigateToDay}
onOpenSlideOver={page.handleOpenSlideOver} onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow} onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow} onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow} onOpenUplifts={page.setUpliftsRow}
onClearSearch={() => filters.handleSearchChange("")} onClearSearch={() => filters.handleSearchChange("")}

View file

@ -18,7 +18,7 @@ type FormFieldProps = FormControlProps & {
const appearanceClassName: Record<FormFieldAppearance, string> = { const appearanceClassName: Record<FormFieldAppearance, string> = {
default: "gap-1", 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<FormFieldAppearance, string> = { const appearanceLabelClassName: Record<FormFieldAppearance, string> = {

View file

@ -0,0 +1,102 @@
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return String(value);
}
}
return "";
}
function 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<string, unknown>,
): 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) ?? []);
}

View file

@ -1,5 +1,6 @@
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board"; import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; 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 { function trimOrUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim(); const trimmed = value?.trim();
@ -42,6 +43,11 @@ export function assignWizardServicePocFields(
payload.pocNotes = pocNotes; payload.pocNotes = pocNotes;
} }
const additionalContacts = normalizeAdditionalContacts(draft.additionalContacts);
if (additionalContacts) {
payload.additionalContacts = additionalContacts;
}
const techPhone = trimOrUndefined(draft.techPhone); const techPhone = trimOrUndefined(draft.techPhone);
if (techPhone) { if (techPhone) {

View file

@ -1,5 +1,7 @@
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board"; 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 { 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"; import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper";
export type BoardFieldOp = { export type BoardFieldOp = {
@ -37,6 +39,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) }, { key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string }, { key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" }, { 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[] { export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {

View file

@ -1,5 +1,9 @@
import { readDocStatusFromRecord } from "@/domain/work-orders/mappers/doc-status-mapper";
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { 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 { import type {
EmployeeOption, EmployeeOption,
LocationOption, LocationOption,
@ -314,9 +318,10 @@ export function mapWorkOrderDetail(raw: unknown): WorkOrderDetail {
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "", vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")), scheduledDate: toDateInputValue(readString(wo, "scheduledDate", "ScheduledDate")),
completedDate: toDateInputValue(readString(wo, "completedDate", "CompletedDate")), 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"), source: readString(wo, "source", "Source"),
type: readDetailWoType(wo),
docStatus: readDetailDocStatus(wo),
mediaCount: readDetailMediaCount(wo),
...(contactIds !== undefined ? { contactIds } : {}), ...(contactIds !== undefined ? { contactIds } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}), ...(categoryIds !== undefined ? { categoryIds } : {}),
comments: Array.isArray(wo.comments) comments: Array.isArray(wo.comments)

View file

@ -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 { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper"; import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import { import {
@ -357,6 +358,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
pocName: readString(item, "pocName", "PocName", "POCName"), pocName: readString(item, "pocName", "PocName", "POCName"),
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"), pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"), pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
additionalContacts: readAdditionalContacts(item),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")), dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0, rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0, carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,

View file

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

View file

@ -0,0 +1,11 @@
export interface WorkOrderAdditionalContact {
name: string;
phone: string;
notes?: string;
}
export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
name: "",
phone: "",
notes: "",
};

View file

@ -1,4 +1,5 @@
import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail"; 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"; import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */ /** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
@ -26,6 +27,7 @@ export interface WorkOrderBoardDetailInfoApi {
pocName?: string | null; pocName?: string | null;
pocPhone?: string | null; pocPhone?: string | null;
pocNotes?: string | null; pocNotes?: string | null;
additionalContacts?: WorkOrderAdditionalContact[] | null;
lifecycleStatus?: number | null; lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null; lifecycleStatusLabel?: string | null;
legacyStatus?: string | null; legacyStatus?: string | null;

View file

@ -1,4 +1,5 @@
import type { DateRangeKey } from "./work-order-advanced-filters"; 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 { WorkOrderTableRow } from "./work-order-table-row";
import type { WizardWOStatus, WOType } from "./work-order-wizard"; import type { WizardWOStatus, WOType } from "./work-order-wizard";
@ -108,6 +109,7 @@ export interface WorkOrderBoardCreatePayload {
pocName?: string; pocName?: string;
pocPhone?: string; pocPhone?: string;
pocNotes?: string; pocNotes?: string;
additionalContacts?: WorkOrderAdditionalContact[];
techPhone?: string; techPhone?: string;
vendorNotes?: string; vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */ /** Severity 1–5 for Emergency/Reactive. */
@ -142,6 +144,7 @@ export type WorkOrderBoardEditableField =
| "serviceNotes" | "serviceNotes"
| "extraServices" | "extraServices"
| "flagColor" | "flagColor"
| "additionalContacts"
| "severity"; | "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */ /** Espelha WorkOrderBoardRowDto (JSON camelCase). */
@ -159,6 +162,7 @@ export interface WorkOrderBoardRowApi {
pocName?: string | null; pocName?: string | null;
pocPhone?: string | null; pocPhone?: string | null;
pocNotes?: string | null; pocNotes?: string | null;
additionalContacts?: WorkOrderAdditionalContact[] | null;
lifecycleStatus?: number | null; lifecycleStatus?: number | null;
lifecycleStatusLabel?: string | null; lifecycleStatusLabel?: string | null;
legacyStatus?: string | null; legacyStatus?: string | null;

View file

@ -1,6 +1,7 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common"; import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch"; import type { WorkOrderDispatchSummary } from "./dispatch";
import type { CompletionDocStatus } from "./work-order-table-row"; import type { CompletionDocStatus } from "./work-order-table-row";
import type { WOType } from "./work-order-wizard";
export interface WorkOrderComment { export interface WorkOrderComment {
id: string | number; id: string | number;
@ -52,9 +53,13 @@ export interface WorkOrderDetail {
vendorNTE: string; vendorNTE: string;
scheduledDate: string; scheduledDate: string;
completedDate: string; completedDate: string;
/** Completion-document status (API DocStatus); persisted via board PATCH. */
docStatus: CompletionDocStatus;
source: string; 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. */ /** Present when GetById returns contacts; used to re-send on edit so backend does not wipe. */
contactIds?: number[]; contactIds?: number[];
/** Present when GetById returns categories; used to re-send on edit so backend does not wipe. */ /** Present when GetById returns categories; used to re-send on edit so backend does not wipe. */

View file

@ -1,3 +1,4 @@
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WizardWOStatus, WOType } from "./work-order-wizard"; import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity"; import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift"; import type { UpliftSummary } from "./work-order-uplift";
@ -46,6 +47,7 @@ export interface WorkOrderTableRow {
pocName: string; pocName: string;
pocPhone: string; pocPhone: string;
pocNotes: string; pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[];
dueDate: string; dueDate: string;
rescheduleCount: number; rescheduleCount: number;
carriedOver: number; carriedOver: number;
@ -94,6 +96,7 @@ export interface WorkOrderTableDraft {
pocName: string; pocName: string;
pocPhone: string; pocPhone: string;
pocNotes: string; pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean; scheduleWeekOnly: boolean;
targetWeek: string; targetWeek: string;
/** Manual Add-On override when schedule is empty at inline create. */ /** Manual Add-On override when schedule is empty at inline create. */
@ -124,6 +127,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
scheduleWeekOnly: false, scheduleWeekOnly: false,
targetWeek: "", targetWeek: "",
isAddOnOverride: false, isAddOnOverride: false,

View file

@ -1,3 +1,4 @@
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity"; import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
export type { WorkOrderSeverity } from "./work-order-severity"; export type { WorkOrderSeverity } from "./work-order-severity";
@ -50,6 +51,8 @@ export interface WorkOrderWizardDraft {
pocName: string; pocName: string;
pocPhone: string; pocPhone: string;
pocNotes: string; pocNotes: string;
/** Secondary POCs beyond primary pocName/pocPhone/pocNotes. */
additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean; scheduleWeekOnly: boolean;
/** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */ /** Monday ISO when week-only; preferred over deriving from scheduledOn (inline create). */
targetWeek?: string; targetWeek?: string;
@ -89,6 +92,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
scheduleWeekOnly: false, scheduleWeekOnly: false,
targetWeek: "", targetWeek: "",
scheduleEnd: "", scheduleEnd: "",

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { import type {
WorkOrderTablePatch, WorkOrderTablePatch,
WorkOrderTableRow, WorkOrderTableRow,
@ -5,8 +6,9 @@ import type {
/** /**
* Fields compared for dirty state and included in Save patch. * Fields compared for dirty state and included in Save patch.
* Only keys that map to board PATCH (or required companions like dispatcherName / vendor labels). * Only keys that map to board PATCH (or required companions like dispatcherName).
* POC, locationId, and tech phone are not board-editable — exclude so Save cannot no-op. * 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 = [ export const SLIDE_OVER_EDIT_KEYS = [
"woNumber", "woNumber",
@ -23,6 +25,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"pm", "pm",
"serviceNotes", "serviceNotes",
"extraServices", "extraServices",
"additionalContacts",
"vendorId", "vendorId",
// company/tech are display companions; board persists vendor via vendorId only // company/tech are display companions; board persists vendor via vendorId only
"status", "status",
@ -37,6 +40,23 @@ function sameExtraServices(a: string[] | undefined, b: string[] | undefined): bo
return left.every((value, index) => value === right[index]); 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( export function isSlideOverDraftDirty(
draft: WorkOrderTableRow, draft: WorkOrderTableRow,
baseline: WorkOrderTableRow, baseline: WorkOrderTableRow,
@ -46,6 +66,12 @@ export function isSlideOverDraftDirty(
if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true; if (!sameExtraServices(draft.extraServices, baseline.extraServices)) return true;
continue; continue;
} }
if (key === "additionalContacts") {
if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) {
return true;
}
continue;
}
if (draft[key] !== baseline[key]) return true; if (draft[key] !== baseline[key]) return true;
} }
return false; return false;
@ -64,6 +90,12 @@ export function buildSlideOverPatch(
} }
continue; continue;
} }
if (key === "additionalContacts") {
if (!sameAdditionalContacts(draft.additionalContacts, baseline.additionalContacts)) {
patch.additionalContacts = draft.additionalContacts;
}
continue;
}
if (draft[key] !== baseline[key]) { if (draft[key] !== baseline[key]) {
(patch as Record<EditKey, WorkOrderTableRow[EditKey]>)[key] = draft[key]; (patch as Record<EditKey, WorkOrderTableRow[EditKey]>)[key] = draft[key];
} }

View file

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

View file

@ -1,3 +1,4 @@
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import type { import type {
CompletionDocStatus, CompletionDocStatus,
WorkOrderUpliftSummary, WorkOrderUpliftSummary,
@ -38,6 +39,11 @@ export function isRealWoNumber(woNumber: string): boolean {
return !/^SH/i.test(trimmed); 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( export function hasPendingUplift(
row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">, row: Pick<WoClosabilityInput, "upliftSummary" | "hasPendingUplift">,
): boolean { ): boolean {
@ -146,3 +152,26 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl
type: draft.type, type: draft.type,
}; };
} }
/**
* Map standalone detail DTO onto closability. Uses authoritative `type` / `docStatus` /
* `mediaCount` (enriched from board detail on the view route). Never treats `source`
* as WorkOrderType. `problem` is the same service field the board maps into `pm`.
*/
export function detailToClosabilityInput(wo: WorkOrderDetail): WoClosabilityInput {
const vendorName = wo.dispatches.find((dispatch) =>
Boolean(dispatch.vendorName.trim()),
)?.vendorName;
return {
dispatcherId: "",
dispatcherName: wo.assignedTo,
company: vendorName ?? "",
tech: "",
completedDate: wo.completedDate,
woNumber: wo.workOrderNumber || wo.internalWONumber,
pm: wo.problem,
docStatus: wo.docStatus,
mediaCount: wo.mediaCount,
type: wo.type,
};
}

View file

@ -58,6 +58,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
pocName: "Amazon Rep", pocName: "Amazon Rep",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -70,6 +70,7 @@ function baseRow(): WorkOrderTableRow {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -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;
}) => (
<button
type="button"
onClick={() =>
onSave({
site: "SEA1",
location: "Seattle",
locationId: "loc-1",
pocName: "Pat",
pocPhone: "111",
pocNotes: "",
additionalContacts: [{ name: "Backup", phone: "555-0101" }],
})
}
>
save site {additionalContacts?.length ?? 0}
</button>
),
}));
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> = {}): 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(
<SlideOverInfoTabEditView
draft={baseRow({ additionalContacts: contacts })}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
allOrders={[]}
onDraftChange={onDraftChange}
/>,
{ 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" }]),
},
]);
});
});

View file

@ -0,0 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const incompleteRow = {
id: "wo-1",
status: "Scheduled",
dispatcherId: "",
dispatcherName: "",
company: "",
tech: "",
completedDate: "",
woNumber: "",
pm: "",
docStatus: "",
mediaCount: 0,
type: "PM",
} as WorkOrderTableRow;
describe("StatusCell closability gate", () => {
it("does not submit Completed when required work-order data is missing", () => {
const onChangeStatus = vi.fn();
render(
<StatusCell wo={incompleteRow} onChangeStatus={onChangeStatus} onOpenSlideOver={vi.fn()} />,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed.hasAttribute("disabled")).toBe(true);
expect(completed.getAttribute("title") ?? "").toContain("Missing:");
fireEvent.click(completed);
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
});
});

View file

@ -50,6 +50,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "", rowVersion: "",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
additionalContacts: [],
...overrides, ...overrides,
}; };
} }

View file

@ -45,6 +45,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
pocName: "Amazon Rep", pocName: "Amazon Rep",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -42,6 +42,7 @@ function renderSiteDialogState() {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
sites: SITES, sites: SITES,
createMode: true, createMode: true,
onSave: vi.fn(), onSave: vi.fn(),

View file

@ -35,6 +35,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -26,7 +26,7 @@ describe("useWoTableRowHandlers onDuplicateFound", () => {
useWoTableRowHandlers({ useWoTableRowHandlers({
tableData: buildTableData(), tableData: buildTableData(),
weekMonday: "2026-07-20", weekMonday: "2026-07-20",
onWeekChange: vi.fn(), onNavigateToDay: vi.fn(),
onOpenSlideOver, onOpenSlideOver,
onOpenMedia: vi.fn(), onOpenMedia: vi.fn(),
onPatch: vi.fn(), onPatch: vi.fn(),

View file

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

View file

@ -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<typeof import("@/domain/work-orders/utils/wizard-date-utils")>();
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 (
<div>
<WeekSelector weekMonday={weekMonday} onChange={handleWeekChange} />
<DayTabs
weekMonday={weekMonday}
selectedDayIso={selectedDayIso}
onChange={setSelectedDayIso}
/>
</div>
);
}
describe("WeekSelector and DayTabs (SH-240)", () => {
it("renders Mon–Fri tabs for the active week", () => {
render(
<DayTabs weekMonday="2026-05-18" selectedDayIso="2026-05-18" onChange={() => 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(<WeekDayNavHarness />);
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(<WeekDayNavHarness />);
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");
});
});

View file

@ -0,0 +1,44 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
describe("WizardStepVendorTime closability gate", () => {
it("disables Completed when closability requirements are missing", () => {
const onUpdate = vi.fn();
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
vendors={[]}
onUpdate={onUpdate}
onPatch={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
const completed = screen.getByRole("button", { name: /^completed$/i });
expect(completed).toBeDisabled();
expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
fireEvent.click(completed);
expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
});
it("allows selecting a non-Completed status", () => {
const onUpdate = vi.fn();
render(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={onUpdate}
onPatch={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
expect(menu).not.toBeNull();
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
});
});

View file

@ -0,0 +1,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> = {},
): 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(
<WoTable
tableData={data}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
weekMonday="2026-05-18"
onNavigateToDay={vi.fn()}
onOpenSlideOver={vi.fn()}
onOpenMedia={vi.fn()}
onClearSearch={vi.fn()}
onPatch={vi.fn()}
/>,
{ 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();
});
});

View file

@ -59,6 +59,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "", rowVersion: "",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
additionalContacts: [],
...overrides, ...overrides,
}; };
} }
@ -179,7 +180,7 @@ function UnassignedAssignHarness({
} }
describe("WoTableBody Unassigned queue (SH-165)", () => { 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 datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
const undatedUnassigned = makeRow({ const undatedUnassigned = makeRow({
id: "us1", id: "us1",
@ -234,26 +235,21 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
const unassignedHeader = screen.getByRole("button", { const unassignedHeader = screen.getByRole("button", {
name: "Unassigned, 2 work orders, expanded", name: "Unassigned, 2 work orders, expanded",
}); });
const unscheduledHeader = screen.getByRole("button", {
name: "Unscheduled, 1 work order, expanded",
});
const dayLabel = screen.getByText("TUE · 06/02"); const dayLabel = screen.getByText("TUE · 06/02");
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe( expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING, Node.DOCUMENT_POSITION_FOLLOWING,
); );
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe( expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING, Node.DOCUMENT_POSITION_FOLLOWING,
); );
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument(); expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getByTestId("row-us1")).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.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 () => { it("collapses and expands the Unassigned section via the disclosure control", async () => {

View file

@ -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 { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; 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 = { const unscheduledRow: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
@ -39,6 +37,7 @@ const unscheduledRow: WorkOrderTableRow = {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "2026-07-15", dueDate: "2026-07-15",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,
@ -66,6 +65,8 @@ const handlers: WoTableRowHandlers = {
onScheduleDate: vi.fn(), onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(), onScheduleWeek: vi.fn(),
onOpenSlideOver: vi.fn(), onOpenSlideOver: vi.fn(),
onOpenCompDoc: vi.fn(),
onOpenUplifts: vi.fn(),
onReorder: vi.fn(), onReorder: vi.fn(),
onDragStart: vi.fn(), onDragStart: vi.fn(),
onDragOver: vi.fn(), onDragOver: vi.fn(),
@ -73,55 +74,47 @@ const handlers: WoTableRowHandlers = {
onDragEnd: vi.fn(), onDragEnd: vi.fn(),
}; };
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */ describe("WoTableBody unscheduled exclusion (SH-240)", () => {
function renderUnscheduledHarness() { it("does not render Unscheduled rows in the day-tab board view", () => {
const tableData = { const tableData = {
advActive: false, advActive: false,
unassigned: [], unassigned: [],
unassignedError: null, unassignedError: null,
unassignedLoading: false, unassignedLoading: false,
unassignedOpen: true, unassignedOpen: true,
setUnassignedOpen: vi.fn(), setUnassignedOpen: vi.fn(),
unscheduled: [unscheduledRow], unscheduled: [unscheduledRow],
unschedOpen: true, unschedOpen: true,
draftDayKey: null, draftDayKey: null,
displayGroups: [], displayGroups: [],
todayKey: null, todayKey: null,
startDayDraft: vi.fn(), startDayDraft: vi.fn(),
setUnschedOpen: vi.fn(), setUnschedOpen: vi.fn(),
} as unknown as ReturnTypeOfUseWorkOrdersTableData; } as unknown as ReturnTypeOfUseWorkOrdersTableData;
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<tr key={String(row.id)}> <tr key={String(row.id)}>
<WoTableRowCells <WoTableRowCells
row={row} row={row}
allRows={[row]} allRows={[row]}
users={[]} users={[]}
sites={[]} sites={[]}
forceScheduleHighlight={forceScheduleHighlight} forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={false} forceDueDateHighlight={false}
handlers={handlers} handlers={handlers}
onPatchRow={vi.fn()} onPatchRow={vi.fn()}
/> />
</tr> </tr>
); );
return renderWithProviders( renderWithProviders(
<table> <table>
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} /> <WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
</table>, </table>,
{ withAuth: false }, { withAuth: false },
); );
}
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => { expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => { expect(screen.queryByText("WO-42")).not.toBeInTheDocument();
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 });
}); });
}); });

View file

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

View file

@ -78,6 +78,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -54,6 +54,7 @@ const baseRow: WorkOrderTableRow = {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -48,7 +48,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
clearAdvancedFilterChip: vi.fn(), clearAdvancedFilterChip: vi.fn(),
activeFilterCount: advApplied ? 1 : 0, activeFilterCount: advApplied ? 1 : 0,
weekMonday: "2026-07-20", weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(), setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: vi.fn(),
dispatcherIds: new Set<string>(), dispatcherIds: new Set<string>(),
setDispatcherIds: vi.fn(), setDispatcherIds: vi.fn(),
typeFilter: "All", 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: "All dispatchers" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Week 7/20–7/24" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled(); expect(screen.getByRole("tab", { name: "Mon · 07/20" })).toBeDisabled();
expect(screen.getByRole("button", { name: "All" })).toBeDisabled(); expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled(); expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
expect(screen.getByText("42 work orders")).toBeInTheDocument(); expect(screen.getByText("42 work orders")).toBeInTheDocument();

View file

@ -44,7 +44,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
clearAdvancedFilterChip: vi.fn(), clearAdvancedFilterChip: vi.fn(),
activeFilterCount: 0, activeFilterCount: 0,
weekMonday: "2026-07-20", weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(), setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: vi.fn(),
dispatcherIds: new Set<string>(), dispatcherIds: new Set<string>(),
setDispatcherIds: vi.fn(), setDispatcherIds: vi.fn(),
typeFilter: "All", typeFilter: "All",
@ -97,6 +100,7 @@ const canceledRow: WorkOrderTableRow = {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

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

View file

@ -41,6 +41,10 @@ const SAMPLE_DETAIL = {
pm: "HVAC PM", pm: "HVAC PM",
serviceNotes: "Check filters", serviceNotes: "Check filters",
extraServices: ["Filter Change"], extraServices: ["Filter Change"],
additionalContacts: [
{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" },
{ name: "Security Desk", phone: "+1 555-0102" },
],
docStatus: 2, docStatus: 2,
flagColor: "#EF4444", flagColor: "#EF4444",
primaryDispatchId: 789, primaryDispatchId: 789,
@ -124,6 +128,10 @@ describe("mapWorkOrderBoardDetail", () => {
expect(detail.info.tech).toBe("Mike Tech"); expect(detail.info.tech).toBe("Mike Tech");
expect(detail.info.pm).toBe("HVAC PM"); expect(detail.info.pm).toBe("HVAC PM");
expect(detail.info.location).toBe("Main Warehouse"); 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.docStatus).toBe("No");
expect(detail.completion.template?.name).toBe("HVAC PM Completion"); expect(detail.completion.template?.name).toBe("HVAC PM Completion");

View file

@ -161,6 +161,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
pocNotes: "Call before arrival", pocNotes: "Call before arrival",
additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
techPhone: "+1 555-0199", techPhone: "+1 555-0199",
vendorNotes: "Gate code 4421", vendorNotes: "Gate code 4421",
@ -204,12 +206,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
vendorNotes: "Gate code 4421", vendorNotes: "Gate code 4421",
locationId: 12, locationId: 12,
additionalContacts: [{ name: "Backup Lead", phone: "+1 555-0101", notes: "After hours" }],
}); });
expect(payload.trade).toBeUndefined(); expect(payload.trade).toBeUndefined();
expect(payload.extraServices).toBeUndefined();
expect(payload.description).toBeUndefined(); expect(payload.description).toBeUndefined();
expect(payload.scheduleWeekOnly).toBeUndefined(); expect(payload.scheduleWeekOnly).toBeUndefined();
@ -376,7 +378,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.vendorNotes).toBe("Also not in description"); 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({ const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,

View file

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

View file

@ -164,6 +164,7 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(row.serviceNotes).toBe("service notes"); expect(row.serviceNotes).toBe("service notes");
expect(row.extraServices).toEqual(["Filter change", "Coil clean"]); expect(row.extraServices).toEqual(["Filter change", "Coil clean"]);
expect(row.docStatus).toBe("Yes"); expect(row.docStatus).toBe("Yes");
expect(row.additionalContacts).toEqual([]);
expect(row.dayGroup).toBe("07/15"); expect(row.dayGroup).toBe("07/15");
expect(row.flagColor).toBe("#EF4444"); expect(row.flagColor).toBe("#EF4444");
}); });
@ -175,6 +176,15 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6"); 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", () => { it("maps uplift summary and pending flag from API payload", () => {
const row = mapWorkOrderTableRow({ const row = mapWorkOrderTableRow({
id: 303, id: 303,

View file

@ -83,6 +83,7 @@ const BASE_ROW: WorkOrderTableRow = {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "", dueDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

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

View file

@ -38,6 +38,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "2026-07-20", dueDate: "2026-07-20",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,
@ -63,6 +64,7 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber"); expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status"); expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId"); 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("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
@ -112,4 +114,14 @@ describe("slide-over-draft", () => {
extraServices: ["HVAC"], 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);
});
}); });

View file

@ -59,6 +59,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "2026-07-20", dueDate: "2026-07-20",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,

View file

@ -54,6 +54,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
pocName: "", pocName: "",
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
additionalContacts: [],
dueDate: "2026-06-30", dueDate: "2026-06-30",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,
@ -268,12 +269,14 @@ describe("buildDayGroups", () => {
const later = makeRow({ const later = makeRow({
id: "2", id: "2",
scheduledOn: "2026-06-24", scheduledOn: "2026-06-24",
completedDate: "",
dayGroup: "06/24", dayGroup: "06/24",
dayLabel: "WED · 06/24", dayLabel: "WED · 06/24",
}); });
const earlier = makeRow({ const earlier = makeRow({
id: "1", id: "1",
scheduledOn: "2026-06-23", scheduledOn: "2026-06-23",
completedDate: "",
dayGroup: "06/23", dayGroup: "06/23",
dayLabel: "TUE · 06/23", dayLabel: "TUE · 06/23",
}); });

View file

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

View file

@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest"; 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 type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability";
import { import {
PENDING_UPLIFT_GAP, PENDING_UPLIFT_GAP,
UPLIFT_PENDING_COMPLETED_TITLE, UPLIFT_PENDING_COMPLETED_TITLE,
canGenerateCompDoc, canGenerateCompDoc,
detailToClosabilityInput,
formatClosabilityMissingTitle, formatClosabilityMissingTitle,
getClosabilityGaps, getClosabilityGaps,
hasPendingUplift, hasPendingUplift,
isCompletionStatusLabel,
isRealWoNumber, isRealWoNumber,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability"; } from "@/domain/work-orders/utils/wo-closability";
function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput { function makeInput(overrides: Partial<WoClosabilityInput> = {}): WoClosabilityInput {
@ -36,6 +42,14 @@ describe("isRealWoNumber", () => {
}); });
}); });
describe("isCompletionStatusLabel", () => {
it("treats Done and Completed as completion labels", () => {
expect(isCompletionStatusLabel("Done")).toBe(true);
expect(isCompletionStatusLabel("Completed")).toBe(true);
expect(isCompletionStatusLabel("Scheduled")).toBe(false);
});
});
describe("hasPendingUplift", () => { describe("hasPendingUplift", () => {
it("is true when hasPendingUplift flag is set", () => { it("is true when hasPendingUplift flag is set", () => {
expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true); 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("");
},
);
});