mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 05:52:04 +00:00
Merge branch 'dev' into feat/sh-250-fe-additive-add
This commit is contained in:
commit
8edcb6659d
93 changed files with 2895 additions and 548 deletions
30
docs/work-orders/pending-be-contract.md
Normal file
30
docs/work-orders/pending-be-contract.md
Normal 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`.
|
||||||
124
e2e/work-orders/week-day-navigation.spec.ts
Normal file
124
e2e/work-orders/week-day-navigation.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/app/(auth)/_components/auth-brand-logo.tsx
Normal file
25
src/app/(auth)/_components/auth-brand-logo.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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[];
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,6 @@ export interface WizardMenuOption {
|
||||||
description?: string;
|
description?: string;
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 || "—"],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export function WizardStepLocationSiteSelect({
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("")}
|
||||||
|
|
|
||||||
|
|
@ -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> = {
|
||||||
|
|
|
||||||
102
src/domain/work-orders/mappers/additional-contacts-mapper.ts
Normal file
102
src/domain/work-orders/mappers/additional-contacts-mapper.ts
Normal 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) ?? []);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface WorkOrderAdditionalContact {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ADDITIONAL_CONTACT: WorkOrderAdditionalContact = {
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: "",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay authoritative closability fields from board detail onto a legacy GetById detail.
|
||||||
|
* Board `info.type`, `completion.docStatus`, and `media.length` are the SH-114/SH-183 sources.
|
||||||
|
*/
|
||||||
|
export function enrichDetailClosabilityFromBoard(
|
||||||
|
detail: WorkOrderDetail,
|
||||||
|
board: WorkOrderBoardDetail | undefined,
|
||||||
|
): WorkOrderDetail {
|
||||||
|
if (!board) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
type: board.info.type,
|
||||||
|
docStatus: board.completion.docStatus,
|
||||||
|
mediaCount: board.media.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
100
src/domain/work-orders/utils/read-detail-closability-fields.ts
Normal file
100
src/domain/work-orders/utils/read-detail-closability-fields.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import {
|
||||||
|
DOC_STATUS_API,
|
||||||
|
WORK_ORDER_TYPE_API_TO_FE,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
// SH-126/SH-184: Add-On is a backend-authoritative indicator (`isAddOn`), not a
|
||||||
|
// selectable business type, so it is no longer part of the WOType union.
|
||||||
|
const DETAIL_WO_TYPES = new Set(["PM", "Reactive", "Emergency", "Overdue"]);
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authoritative WO type only — never falls back to integration `source`. */
|
||||||
|
export function readDetailWoType(record: Record<string, unknown>): WOType | "" {
|
||||||
|
const numeric =
|
||||||
|
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
||||||
|
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
|
||||||
|
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readString(
|
||||||
|
record,
|
||||||
|
"workOrderType",
|
||||||
|
"WorkOrderType",
|
||||||
|
"type",
|
||||||
|
"Type",
|
||||||
|
"woType",
|
||||||
|
"WoType",
|
||||||
|
);
|
||||||
|
if (DETAIL_WO_TYPES.has(raw)) {
|
||||||
|
return raw as WOType;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDetailDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
||||||
|
const numeric = readNumber(record, "docStatus", "DocStatus");
|
||||||
|
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
||||||
|
if (numeric === DOC_STATUS_API.No) return "No";
|
||||||
|
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
||||||
|
|
||||||
|
const nestedCompletion = asRecord(record.completion ?? record.Completion);
|
||||||
|
const nestedNumeric = readNumber(nestedCompletion, "docStatus", "DocStatus");
|
||||||
|
if (nestedNumeric === DOC_STATUS_API.Yes) return "Yes";
|
||||||
|
if (nestedNumeric === DOC_STATUS_API.No) return "No";
|
||||||
|
if (nestedNumeric === DOC_STATUS_API.NotNeeded) return "NN";
|
||||||
|
|
||||||
|
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
|
||||||
|
if (raw === "Yes" || raw === "No" || raw === "NN") {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw === "NotNeeded") {
|
||||||
|
return "NN";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDetailMediaCount(record: Record<string, unknown>): number {
|
||||||
|
const explicit = readNumber(record, "mediaCount", "MediaCount");
|
||||||
|
if (explicit != null) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
const media = record.media ?? record.Media;
|
||||||
|
if (Array.isArray(media)) {
|
||||||
|
return media.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { 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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
src/domain/work-orders/utils/week-day-nav-utils.ts
Normal file
88
src/domain/work-orders/utils/week-day-nav-utils.ts
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ function baseRow(): WorkOrderTableRow {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -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" }]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -50,6 +50,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
rowVersion: "",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
113
src/test/app/(protected)/workorders/week-day-nav.test.tsx
Normal file
113
src/test/app/(protected)/workorders/week-day-nav.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||||
|
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
describe("WizardStepVendorTime closability gate", () => {
|
||||||
|
it("disables Completed when closability requirements are missing", () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
render(
|
||||||
|
<WizardStepVendorTime
|
||||||
|
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
|
||||||
|
vendors={[]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onPatch={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||||
|
const completed = screen.getByRole("button", { name: /^completed$/i });
|
||||||
|
expect(completed).toBeDisabled();
|
||||||
|
expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
|
||||||
|
|
||||||
|
fireEvent.click(completed);
|
||||||
|
expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows selecting a non-Completed status", () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
render(
|
||||||
|
<WizardStepVendorTime
|
||||||
|
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||||
|
vendors={[]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onPatch={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||||
|
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
|
||||||
|
expect(menu).not.toBeNull();
|
||||||
|
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const baseRow: WorkOrderTableRow = {
|
||||||
pocName: "",
|
pocName: "",
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
|
additionalContacts: [],
|
||||||
dueDate: "",
|
dueDate: "",
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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("[]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" }]),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
181
src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
Normal file
181
src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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("");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue