diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index c328af9a..de83e430 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index c05f2378..621f0c0d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 54c553c4..0ccb0cab 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 6c1ca3b4..cbb183eb 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index f5948c0d..1d08f61d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index a1509c39..f1bc8a52 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index c3e9be5b..eeda3e82 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 15eea83d..f319fe67 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index 901b2ab5..0035b2f1 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index a2eeb471..2a83659e 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 7c838f06..b8342ed7 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -97,6 +97,7 @@ async function mockVendorApi( listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean; + groupedDirectory?: boolean; legacyGateway?: boolean; longText?: boolean; } = {}, @@ -147,7 +148,7 @@ async function mockVendorApi( }, }), ); - await page.route("**/api/Vendor/GetVendorList*", async (route) => { + await page.route("**/api/Vendor/GetVendorDirectoryList*", async (route) => { const url = new URL(route.request().url()); state.listUrls.push(url.toString()); if (options.listError && url.searchParams.get("pageSize") !== "1") { @@ -168,8 +169,38 @@ async function mockVendorApi( (!company || vendor.CompanyName === company), ); const isCount = url.searchParams.get("pageSize") === "1"; + const responseRows = filtered.map((vendor) => { + if (!options.groupedDirectory) return vendor; + const technicians = [ + { + Id: vendor.Id, + ContactName: vendor.ContactName, + Email: vendor.Email, + Phone: vendor.Phone, + TradeSpecialties: vendor.TradeSpecialties, + IsActive: vendor.IsActive, + TotalJobs: vendor.TotalJobs, + }, + ]; + if (vendor.Id === 1) { + technicians.push({ + Id: 91, + ContactName: "Robin Rivers", + Email: "robin@gateway.test", + Phone: "314-555-0191", + TradeSpecialties: "Drain Cleaning", + IsActive: true, + TotalJobs: 18, + }); + } + return { + ...vendor, + TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0), + Technicians: technicians, + }; + }); await fulfillJson(route, { - Data: isCount ? filtered.slice(0, 1) : filtered, + Data: isCount ? responseRows.slice(0, 1) : responseRows, TotalCount: filtered.length, PageNumber: 1, PageSize: Number(url.searchParams.get("pageSize") ?? 12), @@ -367,6 +398,43 @@ async function mockVendorApi( } test.describe("Vendor directory prototype parity", () => { + test("renders company-owned columns and the grouped technician roster", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await mockVendorApi(page, { groupedDirectory: true }); + await page.goto("/vendors"); + + const expectedHeaders = [ + "Company", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", + "Trade", + "Technician", + "Technician Phone", + "Total Jobs", + "Status", + ]; + await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1); + for (const header of expectedHeaders) { + await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible(); + } + + const gatewayRow = page.getByRole("button", { + name: "Open vendor details for Gateway Plumbing", + }); + await expect(gatewayRow).toContainText("Adam Whyte"); + await expect(gatewayRow).toContainText("90"); + const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge"); + await expect(rosterBadge).toHaveText("+1"); + await rosterBadge.hover(); + await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers"); + await rosterBadge.focus(); + await expect(rosterBadge).toBeFocused(); + await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers"); + }); + test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { @@ -381,21 +449,24 @@ test.describe("Vendor directory prototype parity", () => { await expect(heading).toHaveCSS("font-weight", "800"); await expect(heading).toHaveCSS("line-height", "33px"); await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); - await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByText("2 active companies").first()).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", ); for (const heading of [ "Company", - "Technician", - "Location", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", "Trade", - "Phone", - "Email", + "Technician", + "Technician Phone", "Total Jobs", ]) { - await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible(); } await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); @@ -403,10 +474,11 @@ test.describe("Vendor directory prototype parity", () => { const tableContainerBox = await page.locator("table").locator("..").boundingBox(); expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); expect(tableContainerBox?.height).toBeCloseTo(738, 0); - expect((await page.locator("table").boundingBox())?.width).toBe(1550); - expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( - 47, - ); + expect((await page.locator("table").boundingBox())?.width).toBe(2050); + expect( + (await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox()) + ?.height, + ).toBe(47); expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); const tradeBadge = page.getByTestId("vendor-trade-badge").first(); @@ -419,13 +491,13 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("dialog")).toHaveCount(0); await tradeBadge.press("Enter"); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByText("2 active technicians")).toBeVisible(); + await expect(page.getByText("2 active companies").last()).toBeVisible(); await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); @@ -442,8 +514,8 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText("1 technician · filtered by")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText("1 company · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active company · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", @@ -451,7 +523,7 @@ test.describe("Vendor directory prototype parity", () => { ).toBe(true); await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No inactive technicians found")).toBeVisible(); + await expect(page.getByText("No inactive companies found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); @@ -466,10 +538,10 @@ test.describe("Vendor directory prototype parity", () => { const row = page.locator("tbody tr").first(); const fields = [ { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, - { cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, - { cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address }, - { cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone }, - { cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email }, + { cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email }, + { cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address }, + { cellIndex: 7, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, + { cellIndex: 8, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, ]; for (const expected of fields) { diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts index 73ddbc30..005c9da0 100644 --- a/e2e/vendors/vendors.visual.spec.ts +++ b/e2e/vendors/vendors.visual.spec.ts @@ -119,7 +119,7 @@ async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = " return; } - if (url.pathname.endsWith("/api/Vendor/GetVendorList")) { + if (url.pathname.endsWith("/api/Vendor/GetVendorDirectoryList")) { const isCount = url.searchParams.get("pageSize") === "1"; if (mode === "error" && !isCount) { await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); @@ -243,8 +243,12 @@ test.describe("Vendor deterministic pixel regression", () => { test("empty", async ({ page }) => { await openVendorPage(page, "empty"); - await expect(page.getByText("No active technicians found")).toBeVisible(); + const emptyState = page.getByText("No active companies found"); + await expect(emptyState).toBeVisible(); await expectStableScreenshot(page, "vendor-empty.png"); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(emptyState).toBeInViewport(); }); test("error", async ({ page }) => { diff --git a/e2e/work-orders/due-date-schedule-floor.spec.ts b/e2e/work-orders/due-date-schedule-floor.spec.ts new file mode 100644 index 00000000..afd181e9 --- /dev/null +++ b/e2e/work-orders/due-date-schedule-floor.spec.ts @@ -0,0 +1,80 @@ +import { expect, test, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "due-date-floor-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) }); +} + +test.describe("Due Date floor in the Create WO wizard (SH-356)", () => { + test.setTimeout(60_000); + + test("disables days before Schedule On and clears Due Date when Schedule On passes it", 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)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + if (route.request().method() !== "GET") { + await fulfillJson(route, {}, 500); + return; + } + await fulfillJson(route, { + data: [], + items: [], + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 0, total: 0 }, + unscheduled: [], + scheduled: [], + }); + }, + ); + + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({ + timeout: 20_000, + }); + + await page.getByRole("button", { name: /Pick a date/ }).click(); + const dateTab = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTab.isVisible()) { + await dateTab.click(); + } + await page.getByRole("button", { name: "20", exact: true }).click(); + + await page.getByRole("button", { name: /No due date/ }).click(); + await expect(page.getByRole("button", { name: "19", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "20", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "21", exact: true }).click(); + await expect(page.getByRole("button", { name: /No due date/ })).toHaveCount(0); + + await page.getByRole("button", { name: /Aug 20/ }).click(); + const dateTabAgain = page.getByRole("button", { name: "Date", exact: true }); + if (await dateTabAgain.isVisible()) { + await dateTabAgain.click(); + } + await page.getByRole("button", { name: "25", exact: true }).click(); + + await expect(page.getByRole("button", { name: /No due date/ })).toBeVisible(); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 37db2b85..33db519b 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -125,6 +125,7 @@ export const API_PATHS = { }, vendor: { getList: "Vendor/GetVendorList", + getDirectoryList: "Vendor/GetVendorDirectoryList", getById: "Vendor/GetById", create: "Vendor/Create", update: "Vendor/Update", diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx new file mode 100644 index 00000000..9053cbe4 --- /dev/null +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -0,0 +1,237 @@ +import { useCallback, useRef, useState, type KeyboardEvent } from "react"; +import { + useFieldArray, + type Control, + type FieldErrors, + type UseFormRegister, +} from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + Box, + Button, + Chip, + IconButton, + Paper, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { + LocationContactFormValues, + LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; + +interface ContactRowProps { + canRemove: boolean; + dragIndex: number | null; + errors: FieldErrors; + fieldId: string; + index: number; + isLast: boolean; + register: UseFormRegister; + onDragEnd: () => void; + onDragStart: (index: number) => void; + onDrop: (index: number) => void; + onRemove: (index: number) => void; + onKeyboardMove: (from: number, to: number) => void; +} + +function ContactRow({ + canRemove, + dragIndex, + errors, + fieldId, + index, + isLast, + register, + onDragEnd, + onDragStart, + onDrop, + onRemove, + onKeyboardMove, +}: ContactRowProps) { + const handleRef = useRef(null); + const isDragging = dragIndex === index; + const isDropTarget = dragIndex !== null && dragIndex !== index; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowUp" && index > 0) { + event.preventDefault(); + onKeyboardMove(index, index - 1); + } + if (event.key === "ArrowDown" && !isLast) { + event.preventDefault(); + onKeyboardMove(index, index + 1); + } + }; + + return ( + { + if (dragIndex !== null) { + event.preventDefault(); + } + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(index); + }} + className="transition-opacity" + sx={{ opacity: isDragging ? 0.5 : 1, borderStyle: isDropTarget ? "dashed" : "solid" }} + > + + + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(index)); + onDragStart(index); + }} + onDragEnd={onDragEnd} + onKeyDown={handleKeyDown} + sx={{ cursor: "grab", display: "inline-flex", color: "text.secondary" }} + > + + + + {index === 0 && } + + + + + onRemove(index)} + > + + + + + + + ); +} + +interface LocationContactsFieldArrayProps { + control: Control; + errors: FieldErrors; + register: UseFormRegister; +} + +export function LocationContactsFieldArray({ + control, + errors, + register, +}: LocationContactsFieldArrayProps) { + const { fields, append, remove, move } = useFieldArray({ + control, + name: "contacts", + keyName: "fieldKey", + }); + const [dragIndex, setDragIndex] = useState(null); + const rootRef = useRef(null); + + const focusHandle = useCallback((fieldId: string) => { + requestAnimationFrame(() => { + const handle = rootRef.current?.querySelector(`[data-field-id="${fieldId}"]`); + handle?.focus(); + }); + }, []); + + const handleKeyboardMove = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= fields.length) { + return; + } + const fieldId = fields[from]?.fieldKey; + move(from, to); + if (fieldId) { + focusHandle(fieldId); + } + }, + [fields, move, focusHandle], + ); + + const handleDrop = useCallback( + (index: number) => { + if (dragIndex !== null && dragIndex !== index) { + move(dragIndex, index); + } + setDragIndex(null); + }, + [dragIndex, move], + ); + + return ( + + + + Contacts + + + + + {fields.map((field, index) => ( + 1} + dragIndex={dragIndex} + errors={errors.contacts?.[index] ?? {}} + fieldId={field.fieldKey} + index={index} + isLast={index === fields.length - 1} + register={register} + onDragEnd={() => setDragIndex(null)} + onDragStart={setDragIndex} + onDrop={handleDrop} + onRemove={remove} + onKeyboardMove={handleKeyboardMove} + /> + ))} + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx index c1f2fb1c..39f22296 100644 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ b/src/app/(protected)/locations/_components/location-form-fields.tsx @@ -12,6 +12,7 @@ import { import type { AccountListItem } from "@/domain/accounts/types/account"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { LocationContactsFieldArray } from "./location-contacts-field-array"; import { StateSelect } from "./state-select"; interface SharedFieldsProps { @@ -109,24 +110,7 @@ export function LocationContactFields({ control, errors, register }: SharedField Contact Information - - - - + 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }], contactEmail: location.contactEmail ?? "", status: (location.status as LocationFormValues["status"]) || "Active", }); diff --git a/src/app/(protected)/locations/_components/locations-contacts-cell.tsx b/src/app/(protected)/locations/_components/locations-contacts-cell.tsx new file mode 100644 index 00000000..60a15f27 --- /dev/null +++ b/src/app/(protected)/locations/_components/locations-contacts-cell.tsx @@ -0,0 +1,46 @@ +import { Chip, Stack, Tooltip, Typography } from "@mui/material"; +import type { LocationListItem } from "@/domain/locations/types/location"; + +/** Main contact name with a `+N` chip; hover/focus lists the remaining contact names. */ +export function LocationsContactsCell({ row }: { row: LocationListItem }) { + const contacts = row.contacts ?? []; + const mainName = row.contact || contacts[0]?.name || ""; + const remainingNames = contacts + .slice(1) + .map((contact) => contact.name.trim()) + .filter((name) => name !== ""); + + if (mainName === "") { + return ( + + — + + ); + } + + if (remainingNames.length === 0) { + return ( + + {mainName} + + ); + } + + const remainingLabel = remainingNames.join(", "); + + return ( + + + {mainName} + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index 030031d7..bfd36118 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -15,6 +15,7 @@ import { Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; +import { LocationsContactsCell } from "./locations-contacts-cell"; import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { @@ -74,7 +75,9 @@ export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsT {row.phone || "—"} - {row.contact || "—"} + + + {row.status || "—"} diff --git a/src/app/(protected)/vendors/_components/vendor-table-row.tsx b/src/app/(protected)/vendors/_components/vendor-table-row.tsx new file mode 100644 index 00000000..e4ea83db --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-table-row.tsx @@ -0,0 +1,339 @@ +import { type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Avatar, Box, IconButton, Link, Stack, TableCell, TableRow, Tooltip } from "@mui/material"; +import { VendorStatusBadge } from "./vendor-status-badge"; +import { Text } from "@/components/ui/text"; +import type { VendorDirectoryTechnician, VendorListItem } from "@/domain/vendors/types/vendor"; + +function stopPropagation(event: MouseEvent): void { + event.stopPropagation(); +} +function stopBadgeKeyDown(event: KeyboardEvent): void { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); +} + +const truncatedTextSx = { + display: "block", + minWidth: 0, + maxWidth: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +function TruncatedText({ + value, + testId, + focusable = false, + href, + variant, +}: { + value: string; + testId: string; + focusable?: boolean; + href?: string; + variant?: "description"; +}) { + const hasValue = value !== "—"; + const content = href ? ( + + {value} + + ) : variant ? ( + + {value} + + ) : ( + + {value} + + ); + return ( + + {content} + + ); +} + +function OverflowBadge({ + count, + label, + kind, +}: { + count: number; + label: string; + kind: "trade" | "technician"; +}) { + const noun = count === 1 ? kind : `${kind}s`; + const ariaLabel = `+${count} more ${noun}${kind === "trade" ? `: ${label}` : ""}`; + return ( + + + +{count} + + + ); +} + +function CellText({ + value, + testId, + href, + focusable, +}: { + value: string; + testId: string; + href?: string; + focusable?: boolean; +}) { + return ( + + + + ); +} + +function CompanyCells({ row }: { row: VendorListItem }) { + const phone = row.companyPhone.trim(); + const email = row.email.trim(); + return ( + <> + + + + + + + + + + ); +} + +function orderedTechnicians(row: VendorListItem): VendorDirectoryTechnician[] { + const technicians = row.technicians ?? []; + const primaryIndex = + row.id == null ? -1 : technicians.findIndex((item) => String(item.id) === String(row.id)); + if (primaryIndex <= 0) return technicians; + return [technicians[primaryIndex]!, ...technicians.filter((_, index) => index !== primaryIndex)]; +} + +function primaryTechnician(row: VendorListItem, technicians: VendorDirectoryTechnician[]) { + return ( + technicians[0] ?? { + id: row.id ?? "", + contactName: row.contactName, + email: "", + phone: row.phone, + preferredContact: row.preferredContact, + tradeSpecialties: row.tradeSpecialties, + isActive: row.isActive, + totalJobs: row.totalJobs, + } + ); +} + +function TradeCell({ value }: { value: string }) { + const trades = value + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); + const omitted = trades.slice(1); + return ( + + {trades.length === 0 ? ( + "—" + ) : ( + + {trades[0]} + {omitted.length > 0 && ( + + )} + + )} + + ); +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + return `${parts[0]?.[0] ?? ""}${parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""}`.toUpperCase(); +} + +function TechnicianCells({ row }: { row: VendorListItem }) { + const technicians = orderedTechnicians(row); + const primary = primaryTechnician(row, technicians); + const omitted = technicians.slice(1); + const phone = primary.phone.trim(); + return ( + <> + + + + {getInitials(primary.contactName)} + + + {omitted.length > 0 && ( + item.contactName || "Unnamed technician").join(", ")} + kind="technician" + /> + )} + + + + + ); +} + +function RowActions({ + row, + onOpenDetail, + onOpenEdit, +}: { + row: VendorListItem; + onOpenDetail: () => void; + onOpenEdit: () => void; +}) { + const action = (label: string, icon: ReactNode, handler: () => void) => ( + + { + event.stopPropagation(); + handler(); + }} + > + {icon} + + + ); + return ( + + + {action("View", , onOpenDetail)} + {action("Edit", , onOpenEdit)} + + + ); +} + +export function VendorTableRow({ + row, + onOpenDetail, + onOpenEdit, +}: { + row: VendorListItem; + onOpenDetail: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; +}) { + const openDetail = () => onOpenDetail(row); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof Element && event.target.closest("a, button")) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + openDetail(); + }; + return ( + + + + + {row.totalJobs ?? 0} + + + + + + onOpenEdit(row)} /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index b8ad2a9c..9e61d66e 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -6,7 +6,6 @@ import { Text } from "@/components/ui/text"; import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { - globalCount: number; activeCount: number; isFetching: boolean; isLoading: boolean; @@ -18,7 +17,6 @@ interface VendorsListToolbarProps { } export function VendorsListToolbar({ - globalCount, activeCount, isFetching, isLoading, @@ -33,7 +31,7 @@ export function VendorsListToolbar({ surface className="-mx-4 -mt-4" title="Vendors / Technicians" - subtitle={`${globalCount} technicians · ${activeCount} active`} + subtitle={`${activeCount} active ${activeCount === 1 ? "company" : "companies"}`} actions={ <> - {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {resultCount} {resultCount === 1 ? "company" : "companies"} · filtered by {chips.map((chip) => ( - {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} + {totalCount} {isActive ? "active" : "inactive"} {totalCount === 1 ? "company" : "companies"} {isFiltered && " · filtered"} diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 3e851435..acbf10c6 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,13 +1,6 @@ -import { type KeyboardEvent, type MouseEvent } from "react"; import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { - Avatar, - Box, CircularProgress, - IconButton, - Link, Paper, Stack, Table, @@ -16,297 +9,24 @@ import { TableContainer, TableHead, TableRow, - Tooltip, } from "@mui/material"; -import { VendorStatusBadge } from "./vendor-status-badge"; +import { VendorTableRow } from "./vendor-table-row"; import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ "Company", - "Technician", - "Location", + "Company Phone", + "Company Email", + "Street Address", + "City", + "State", "Trade", - "Phone", - "Email", + "Technician", + "Technician Phone", "Total Jobs", ] as const; -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - -function splitTrades(value: string | undefined): string[] { - return (value ?? "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); -} - -function stopPropagation(event: MouseEvent): void { - event.stopPropagation(); -} - -function stopTradeBadgeKeyDown(event: KeyboardEvent): void { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); -} - -interface TruncatedTextProps { - value: string; - testId: string; - focusable?: boolean; - href?: string; - color?: string; - variant?: "description"; -} - -const truncatedTextSx = { - display: "block", - minWidth: 0, - maxWidth: "100%", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}; - -function TruncatedText({ - value, - testId, - focusable = false, - href, - color = "inherit", - variant, -}: TruncatedTextProps) { - const hasValue = value !== "—"; - const content = href ? ( - - {value} - - ) : variant ? ( - - {value} - - ) : ( - - {value} - - ); - - return ( - - {content} - - ); -} - -function getVendorDisplayValues(row: VendorListItem) { - const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", "); - return { - locationText: cityStateText || row.address.trim() || "—", - phoneText: row.phone.trim() || row.companyPhone.trim(), - }; -} - -interface VendorTableRowProps { - row: VendorListItem; - onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; -} - -function VendorTradeCell({ - primaryTrade, - omittedTrades, -}: { - primaryTrade: string; - omittedTrades: string[]; -}) { - const extraTrades = omittedTrades.length; - return ( - - {primaryTrade === "—" ? ( - "—" - ) : ( - - {primaryTrade} - {extraTrades > 0 && ( - - - +{extraTrades} - - - )} - - )} - - ); -} - -function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { - const trades = splitTrades(row.tradeSpecialties); - const primaryTrade = trades[0] ?? "—"; - const omittedTrades = trades.slice(1); - const { locationText, phoneText } = getVendorDisplayValues(row); - const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - onOpenDetail(row); - }; - - return ( - onOpenDetail(row)} - onKeyDown={handleKeyDown} - > - - - - - - - {getInitials(row.contactName)} - - - - - - - - - - - - - - - {row.totalJobs ?? 0} - - - - - - - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - { - event.stopPropagation(); - onOpenEdit(row); - }} - > - - - - - - - ); -} - interface VendorsTableProps { rows: VendorListItem[]; isActive: boolean; @@ -328,7 +48,6 @@ export function VendorsTable({ }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; const columnCount = TABLE_COLUMNS.length + 2; - return ( - - - - - - - - - + {[190, 160, 220, 220, 140, 100, 190, 220, 170, 110, 120, 130].map((width, index) => ( + + ))} @@ -403,18 +112,21 @@ export function VendorsTable({ {isLoading ? ( - - + + + + ) : showEmptyRow ? ( - - + + - - {`No ${isActive ? "active" : "inactive"} technicians found`} - + {`No ${isActive ? "active" : "inactive"} companies found`} {isFiltered ? "Try a different search, or add a new vendor." diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 48447ad6..a9968f39 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -15,7 +15,7 @@ import { useVendorListState } from "./_components/use-vendor-list-state"; import type { VendorFilters } from "./_components/vendor-filters"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; +import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; interface AppliedChip { category: keyof VendorFilters; @@ -125,9 +125,17 @@ export default function VendorsListPage() { const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); - const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); - const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); - const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); + const { data, isLoading, isFetching, error } = useVendorDirectoryList(filters.listParams); + const { data: activeCountData } = useVendorDirectoryList({ + isActive: true, + page: 1, + pageSize: 1, + }); + const { data: inactiveCountData } = useVendorDirectoryList({ + isActive: false, + page: 1, + pageSize: 1, + }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -143,7 +151,6 @@ export default function VendorsListPage() { return ( - {formatAuditTime(entry.createdAt, isSys)} + {formatAuditTimestamp(entry.createdAt)} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index 290ec603..c44465b3 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -6,7 +6,7 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work- import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media"; import { formatMediaSizeLimit, isWorkOrderFileTooLarge, @@ -16,10 +16,6 @@ import { SlideOverCompletionMedia } from "./slide-over-completion-media"; import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status"; import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs"; -function isMediaWO(type: WOType | ""): boolean { - return type === "Emergency" || type === "Reactive"; -} - type SlideOverCompletionTabProps = { row: WorkOrderTableRow; isReadOnly: boolean; @@ -56,7 +52,7 @@ export function SlideOverCompletionTab({ const templateUrl = completion?.template?.templateUrl; const signOffAttachment = completion?.signOffAttachment; - if (isMediaWO(row.type)) { + if (hasCompletionMediaGallery(row.type)) { return ( = { other: Paperclip, }; +const FILETYPE_LABEL: Record = { + image: "Photo", + video: "Video", + pdf: "PDF", + doc: "Document", + other: "File", +}; + const EXTRA_DOC_PURPOSES: Array> = [ "Extra", "Aveta", @@ -92,8 +100,13 @@ function ExtraDocRow({ const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra"; return ( -
+
onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, scheduledOn), scheduledOn, scheduleWeekOnly: false, targetWeek: scheduledOn ? "" : draft.targetWeek, @@ -63,6 +68,7 @@ function SlideOverScheduleOnField({ } onSaveWeek={(weekStart) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, weekStart), scheduledOn: "", targetWeek: weekStart, scheduleWeekOnly: true, @@ -74,6 +80,7 @@ function SlideOverScheduleOnField({ } onSaveRange={(start, end) => onDraftChange({ + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -175,6 +182,7 @@ export function SlideOverInfoTabEditView({ onDraftChange({ dueDate })} /> diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 346ecb57..5bde5578 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -12,6 +12,7 @@ import type { UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, type MediaSurfaceOwnership, @@ -80,6 +81,7 @@ export function useSlideOverMediaState({ categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const workOrderId = detailForRow?.info.id; + const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type); const [mediaBatchPending, setMediaBatchPending] = useState(false); const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), @@ -116,16 +118,16 @@ export function useSlideOverMediaState({ useMediaUploadSession(detailForRow, persistCompletionUpload); const completionMedia = useMemo( - () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), - [uploads, ownership], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)), + [uploads, ownership, completionHasGallery], ); const extrasUploads = useMemo( () => boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => - isExtrasSurfaceMedia(item, ownership), + isExtrasSurfaceMedia(item, ownership, completionHasGallery), ), - [detailForRow?.media, ownership], + [detailForRow?.media, ownership, completionHasGallery], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index 12b5a42e..5c76c5a6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -25,6 +25,8 @@ type DateCellProps = { onClear?: () => void; /** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */ srStatusSuffix?: string; + /** Earliest selectable day in the date calendar (SH-356). */ + minDate?: string; }; export function DateCell({ @@ -41,6 +43,7 @@ export function DateCell({ onSaveRange, onClear, srStatusSuffix, + minDate, }: DateCellProps) { const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -154,6 +157,7 @@ export function DateCell({ ) : ( { onSave(d); setAnchorEl(null); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx index a47b8e14..3f5d4915 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-calendar-dialog.tsx @@ -8,6 +8,7 @@ type DueDateCalendarDialogProps = { onOpenChange: (open: boolean) => void; value: string; onSave: (iso: string) => void; + minDate?: string; }; /** @@ -19,6 +20,7 @@ export function DueDateCalendarDialog({ onOpenChange, value, onSave, + minDate, }: DueDateCalendarDialogProps) { return ( { onSave(iso); onOpenChange(false); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx index 1ce90d18..5f09b0a4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/due-date-cell.tsx @@ -16,6 +16,8 @@ type DueDateCellProps = { * read by colour alone. */ pastDue?: boolean; + /** Schedule On floor: earlier days are not selectable (SH-356). */ + minDate?: string; onSave: (date: string) => void; }; @@ -29,6 +31,7 @@ export function DueDateCell({ q, highlight, pastDue = false, + minDate, onSave, }: DueDateCellProps) { const [open, setOpen] = useState(false); @@ -40,6 +43,7 @@ export function DueDateCell({ q={q} highlight={highlight} onSave={onSave} + minDate={minDate} srStatusSuffix={pastDue ? "Past due" : undefined} /> ); @@ -87,7 +91,13 @@ export function DueDateCell({ style={{ color: "var(--muted-foreground)" }} /> - + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 369c1101..39744598 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -22,6 +22,8 @@ type EditableWONumberCellProps = { onSave: (v: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; locked?: boolean; + /** Empty-state label for create flows; renders with a persistent required asterisk. */ + requiredPlaceholder?: string; }; async function resolveDuplicateWoNumber( @@ -34,6 +36,28 @@ async function resolveDuplicateWoNumber( return remote && String(remote.id) !== String(currentId) ? remote : undefined; } +function EmptyWoNumberLabel({ + value, + q, + requiredPlaceholder, +}: { + value: string; + q?: string; + requiredPlaceholder?: string; +}) { + if (value) { + return ; + } + if (!requiredPlaceholder) { + return "-"; + } + return ( + + {requiredPlaceholder} * + + ); +} + export function EditableWONumberCell({ value, rescheduleCount, @@ -49,6 +73,7 @@ export function EditableWONumberCell({ onSave, onDuplicateFound, locked = false, + requiredPlaceholder, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -162,7 +187,7 @@ export function EditableWONumberCell({ padding: "2px 4px", }} > - {value ? : "-"} + )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx index 48ecbec6..bdccff61 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx @@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = { pocNotes: string; additionalContacts: WorkOrderAdditionalContact[]; showPrimaryErrors?: boolean; + primaryRequired?: boolean; onPocNameChange: (value: string) => void; onPocPhoneChange: (value: string) => void; onPocNotesChange: (value: string) => void; @@ -19,6 +20,7 @@ export function SiteDialogPocSection({ pocNotes, additionalContacts, showPrimaryErrors = false, + primaryRequired = false, onPocNameChange, onPocPhoneChange, onPocNotesChange, @@ -31,6 +33,7 @@ export function SiteDialogPocSection({ pocNotes={pocNotes} additionalContacts={additionalContacts} showPrimaryErrors={showPrimaryErrors} + primaryRequired={primaryRequired} onPocNameChange={onPocNameChange} onPocPhoneChange={onPocPhoneChange} onPocNotesChange={onPocNotesChange} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 239c883e..06c5e6bb 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -86,7 +86,7 @@ export function SiteDialog({
- Site + Site void; onRequestComplete?: () => void; readOnly?: boolean; + /** Explicit list for inline create; existing WOs derive valid transitions from `wo.status`. */ statusOptions?: readonly WizardWOStatus[]; }; @@ -128,10 +129,11 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, - statusOptions = BOARD_INLINE_STATUSES, + statusOptions, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); + const options = statusOptions ?? getInlineStatusTransitions(wo.status); if (readOnly) { return ; @@ -169,7 +171,7 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }} > - {statusOptions.map((status) => { + {options.map((status) => { const option = getStatusOptionState(wo, status, closabilityGaps); return ( diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index ae33d713..acea0710 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -9,6 +9,7 @@ import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify- import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify"; import type { WoTableRowHandlers } from "./wo-table-row"; +import { clearDueDateBeforeSchedule } from "@/domain/work-orders/utils/due-date-floor"; function revealOrNotifyScheduledDay(args: { id: string | number; @@ -31,6 +32,35 @@ function revealOrNotifyScheduledDay(args: { revealScheduledRow(id); } +type DragHandlersArgs = Pick< + ReturnTypeOfUseWorkOrdersTableData, + "dragState" | "setDragViz" | "reorderRows" | "endDrag" +>; + +function createDragHandlers({ dragState, setDragViz, reorderRows, endDrag }: DragHandlersArgs) { + return { + onDragStart: (id: string | number) => { + dragState.current = { dragId: id, overId: null }; + setDragViz({ dragId: id, overId: null }); + }, + onDragOver: (id: string | number) => { + const did = dragState.current.dragId; + if (did && did !== id && dragState.current.overId !== id) { + dragState.current.overId = id; + setDragViz((v) => ({ ...v, overId: id })); + } + }, + onDrop: () => { + const { dragId, overId } = dragState.current; + if (dragId && overId) { + reorderRows(dragId, overId); + } + endDrag(); + }, + onDragEnd: endDrag, + }; +} + type UseWoTableRowHandlersOptions = { tableData: ReturnTypeOfUseWorkOrdersTableData; weekMonday: string; @@ -101,8 +131,11 @@ export function useWoTableRowHandlers({ onPatch: (id, patch, options) => { onPatch(id, patch, options); }, - onScheduleDate: (id: string | number, date: string) => { - onPatch(id, applySchedulePatch(date, false)); + onScheduleDate: (id: string | number, date: string, dueDate?: string) => { + onPatch(id, { + ...applySchedulePatch(date, false), + ...clearDueDateBeforeSchedule(dueDate, date), + }); revealOrNotifyScheduledDay({ id, date, @@ -111,8 +144,11 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleRange: (id: string | number, start: string, end: string) => { - onPatch(id, applyScheduleRangePatch(start, end)); + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => { + onPatch(id, { + ...applyScheduleRangePatch(start, end), + ...clearDueDateBeforeSchedule(dueDate, start), + }); revealOrNotifyScheduledDay({ id, date: start, @@ -121,7 +157,7 @@ export function useWoTableRowHandlers({ revealScheduledRow, }); }, - onScheduleWeek: (id: string | number, week: string) => { + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => { const isClear = week === ""; onPatch( id, @@ -135,6 +171,7 @@ export function useWoTableRowHandlers({ schedMode: "week", dayGroup: "", dayLabel: "", + ...clearDueDateBeforeSchedule(dueDate, week), }, ); weekMoveNotify(week, { @@ -153,25 +190,12 @@ export function useWoTableRowHandlers({ onRequestComplete, onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onReorder: reorderRows, - onDragStart: (id: string | number) => { - dragStateRef.current = { dragId: id, overId: null }; - setDragViz({ dragId: id, overId: null }); - }, - onDragOver: (id: string | number) => { - const did = dragStateRef.current.dragId; - if (did && did !== id && dragStateRef.current.overId !== id) { - dragStateRef.current.overId = id; - setDragViz((v) => ({ ...v, overId: id })); - } - }, - onDrop: () => { - const { dragId, overId } = dragStateRef.current; - if (dragId && overId) { - reorderRows(dragId, overId); - } - endDrag(); - }, - onDragEnd: endDrag, + ...createDragHandlers({ + dragState: dragStateRef, + setDragViz, + reorderRows, + endDrag, + }), }), [ tableData, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx index 47ca7b46..50915a28 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx @@ -89,6 +89,7 @@ export function WoTableInlineIdentityCells({ allOrders={allRows as never[]} onSave={(v) => onPatch({ woNumber: v })} onDuplicateFound={onDuplicateFound} + requiredPlaceholder="Insert WO #" /> +
+ ); +} + function InlineTypeSeverityCell({ draft, onPatch, @@ -51,20 +67,22 @@ function InlineTypeSeverityCell({ onPatch: (patch: Partial) => void; }) { return ( - + ); } function InlineMutedDashCell() { return ( - ); } @@ -81,18 +99,18 @@ function InlineScheduleOnCell({ const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); return ( - + ); } @@ -184,26 +204,26 @@ export function WoTableInlineRowCells({ - + - - - - - + diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index f6258ee0..ffce7992 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -115,15 +115,17 @@ export function WoTableInlineRow({ pointerEvents: saving ? "none" : undefined, }} > - setConfirmComplete(true)} /> - @@ -163,6 +164,7 @@ export function WoTableRowCells({ q={search} highlight={forceDueDateHighlight || row.isPastDue} pastDue={row.isPastDue} + minDate={resolveDueDateFloor(row)} onSave={(date) => onPatchRow({ dueDate: date })} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index 1abf21e5..8a7814ec 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -36,9 +36,10 @@ export type WoTableRowHandlers = { patch: Partial, options?: WorkOrderTablePatchOptions, ) => void; - onScheduleDate: (id: string | number, date: string) => void; - onScheduleWeek: (id: string | number, week: string) => void; - onScheduleRange: (id: string | number, start: string, end: string) => void; + /** `dueDate` is the row's current Due Date, cleared when the new schedule passes it (SH-356). */ + onScheduleDate: (id: string | number, date: string, dueDate?: string) => void; + onScheduleWeek: (id: string | number, week: string, dueDate?: string) => void; + onScheduleRange: (id: string | number, start: string, end: string, dueDate?: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; onOpenSlideOver: (row: WorkOrderTableRow) => void; onRequestComplete?: (row: WorkOrderTableRow) => void; @@ -77,17 +78,17 @@ function guardScheduleHandlers( if (!canApplyWorkOrderTablePatch(row.status, patch)) return; handlers.onPatch(id, patch, options); }, - onScheduleDate: (id, date) => { + onScheduleDate: (id, date, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; - handlers.onScheduleDate(id, date); + handlers.onScheduleDate(id, date, dueDate); }, - onScheduleWeek: (id, week) => { + onScheduleWeek: (id, week, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return; - handlers.onScheduleWeek(id, week); + handlers.onScheduleWeek(id, week, dueDate); }, - onScheduleRange: (id, start, end) => { + onScheduleRange: (id, start, end, dueDate) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return; - handlers.onScheduleRange(id, start, end); + handlers.onScheduleRange(id, start, end, dueDate); }, }; } diff --git a/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx index 0449fadd..c12c2eab 100644 --- a/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx +++ b/src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; @@ -23,6 +24,8 @@ type PocContactFieldsProps = { onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void; helperText?: string; showPrimaryErrors?: boolean; + /** Labels the primary name/phone inputs with a persistent required asterisk. */ + primaryRequired?: boolean; }; function extraNameMissing(contact: WorkOrderAdditionalContact): boolean { @@ -135,7 +138,9 @@ export function PocContactFields({ onAdditionalContactsChange, helperText, showPrimaryErrors = false, + primaryRequired = false, }: PocContactFieldsProps) { + const fieldId = useId(); const nameMissing = showPrimaryErrors && !pocName.trim(); const phoneMissing = showPrimaryErrors && !pocPhone.trim(); @@ -153,7 +158,13 @@ export function PocContactFields({ )}
+ {primaryRequired && ( + + POC name + + )} onPocNameChange(event.target.value)} placeholder="POC name" @@ -168,7 +179,13 @@ export function PocContactFields({
+ {primaryRequired && ( + + POC phone + + )} onPocPhoneChange(event.target.value)} placeholder="POC phone" diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx index e9aa9dab..5b494167 100644 --- a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx @@ -12,6 +12,8 @@ type MiniCalendarProps = { range?: boolean; rangeStart?: string; rangeEnd?: string; + /** Days before this ISO date are disabled (SH-356 Due Date floor). */ + minDate?: string; }; export function MiniCalendar({ @@ -20,9 +22,11 @@ export function MiniCalendar({ range, rangeStart = "", rangeEnd = "", + minDate = "", }: MiniCalendarProps) { const initRaw = range ? rangeStart || value : value; - const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : todayIso(); + const fallback = minDate > todayIso() ? minDate : todayIso(); + const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : fallback; const [iy, im] = init.split("-").map(Number); const [viewY, setViewY] = useState(iy); const [viewM, setViewM] = useState(im); @@ -94,6 +98,7 @@ export function MiniCalendar({ } const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`; const isToday = iso === today; + const beforeMin = Boolean(minDate) && iso < minDate; const { bg, fg, radius, filled } = getMiniCalendarCellStyle({ iso, value, @@ -107,8 +112,9 @@ export function MiniCalendar({ - + ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx index 61bf4927..c7916686 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx @@ -26,6 +26,7 @@ export function WizardStepLocationPocFields({ onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)} helperText="Auto-filled from records — edit if outdated" showPrimaryErrors={showErrors && pocIncomplete} + primaryRequired /> ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index f4df71a3..400ad771 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -29,6 +29,10 @@ import { import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker"; import { CheckCircle2, Clock, Flame, Users, Wrench, Zap } from "lucide-react"; import type { ElementType } from "react"; +import { + clearDueDateBeforeSchedule, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; const WO_TYPE_ICONS: Record = { PM: Wrench, @@ -165,6 +169,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChange={(iso) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, iso), scheduledOn: iso, scheduleWeekOnly: false, scheduleEnd: "", @@ -177,6 +182,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeWeek={(w) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, w), scheduledOn: w, scheduleWeekOnly: Boolean(w), scheduleEnd: "", @@ -189,6 +195,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onChangeRange={(s, e) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, s), scheduledOn: s, scheduleEnd: e, schedMode: "range", @@ -211,6 +218,7 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul onUpdate("dueDate", iso)} /> diff --git a/src/domain/locations/mappers/location-mapper.ts b/src/domain/locations/mappers/location-mapper.ts index 4e672fa5..6713c559 100644 --- a/src/domain/locations/mappers/location-mapper.ts +++ b/src/domain/locations/mappers/location-mapper.ts @@ -1,4 +1,9 @@ -import type { Location, LocationListItem, LocationSite } from "@/domain/locations/types/location"; +import type { + Location, + LocationContact, + LocationListItem, + LocationSite, +} from "@/domain/locations/types/location"; function readString(record: Record, ...keys: string[]): string { for (const key of keys) { @@ -90,9 +95,31 @@ export function mapLocationSites(rawItems: unknown[]): LocationSite[] { return rawItems.map(mapLocationSite); } +/** Reads the additive contacts array; returns [] when absent or malformed. */ +export function mapLocationContacts(raw: unknown): LocationContact[] { + if (!Array.isArray(raw)) { + return []; + } + return raw.map((entry) => { + const item = (typeof entry === "object" && entry !== null ? entry : {}) as Record< + string, + unknown + >; + const id = item.id ?? item.Id; + return { + ...(typeof id === "number" && Number.isInteger(id) && id > 0 ? { id } : {}), + name: readString(item, "name", "Name", "contact", "Contact"), + phone: readString(item, "phone", "Phone"), + }; + }); +} + export function mapLocationListItem(raw: unknown): LocationListItem { const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const contacts = mapLocationContacts(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + return { id: readId(item), name: readString(item, "name", "Name"), @@ -100,11 +127,14 @@ export function mapLocationListItem(raw: unknown): LocationListItem { city: readString(item, "city", "City"), state: readString(item, "state", "State"), zipCode: readString(item, "zipCode", "ZipCode", "Zip"), - phone: readString(item, "phone", "Phone"), - contact: readString(item, "contact", "Contact", "contactPerson", "ContactPerson"), + phone: mainContact ? mainContact.phone : readString(item, "phone", "Phone"), + contact: mainContact + ? mainContact.name + : readString(item, "contact", "Contact", "contactPerson", "ContactPerson"), contactEmail: readString(item, "contactEmail", "ContactEmail"), status: readString(item, "status", "Status"), accountId: readOptionalId(item), + contacts: contacts.length > 0 ? contacts : undefined, }; } @@ -122,6 +152,12 @@ export function mapLocation(raw: unknown): Location { .join(", ") || undefined; + const contacts = mapLocationContacts(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + const legacyPhone = readString(item, "phone", "Phone") || undefined; + const legacyContact = + readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined; + return { id: readId(item) || undefined, name: readString(item, "name", "Name") || undefined, @@ -129,31 +165,58 @@ export function mapLocation(raw: unknown): Location { city: readString(item, "city", "City") || undefined, state: readString(item, "state", "State") || undefined, zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined, - phone: readString(item, "phone", "Phone") || undefined, - contact: readString(item, "contact", "Contact") || undefined, + phone: mainContact ? mainContact.phone : legacyPhone, + contact: mainContact ? mainContact.name : legacyContact, contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined, status: readString(item, "status", "Status") || undefined, accountId: readOptionalId(item) || undefined, + contacts: contacts.length > 0 ? contacts : undefined, }; } +/** Request payload contacts: trimmed, in displayed order. */ +export function mapContactsToBackend(raw: unknown): LocationContact[] { + if (!Array.isArray(raw)) { + return []; + } + return raw + .map((entry) => { + const item = (typeof entry === "object" && entry !== null ? entry : {}) as Record< + string, + unknown + >; + return { + ...(typeof item.id === "number" && Number.isInteger(item.id) && item.id > 0 + ? { id: item.id } + : {}), + name: readString(item, "name", "Name", "contact", "Contact").trim(), + phone: readString(item, "phone", "Phone").trim(), + }; + }) + .filter((contact) => contact.name !== "" || contact.phone !== ""); +} + export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, unknown >; + const contacts = mapContactsToBackend(item.contacts ?? item.Contacts); + const mainContact = contacts[0]; + const payload: Record = { name: item.name ?? "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: item.phone ?? "", - contact: item.contact ?? "", + phone: mainContact ? mainContact.phone : (item.phone ?? ""), + contact: mainContact ? mainContact.name : (item.contact ?? ""), contactEmail: item.contactEmail ?? "", status: item.status ?? "Active", accountId: item.accountId ?? "", + contacts, }; const id = item.id ?? item.Id; diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 00fbbdbb..7c9e878c 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -2,6 +2,16 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +export const locationContactSchema = z.object({ + id: z.number().int().positive().optional(), + name: z + .string() + .trim() + .min(1, "Contact person is required") + .max(100, "Contact person cannot exceed 100 characters"), + phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), +}); + export const locationSchema = z.object({ name: z.string().min(1, "Name is required"), accountId: z.string().optional(), @@ -11,13 +21,13 @@ export const locationSchema = z.object({ .union([z.enum(US_STATE_CODES), z.literal("")]) .refine((state): boolean => state !== "", { message: "State is required" }), zipCode: z.string().min(1, "Zip code is required"), - phone: z.string().min(1, "Phone is required"), - contact: z.string().min(1, "Contact person is required"), + contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), }); export type LocationFormValues = z.infer; +export type LocationContactFormValues = z.infer; export const locationDefaultValues: LocationFormValues = { name: "", @@ -26,8 +36,7 @@ export const locationDefaultValues: LocationFormValues = { city: "", state: "", zipCode: "", - phone: "", - contact: "", + contacts: [{ name: "", phone: "" }], contactEmail: "", status: "Active", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 07ecbe2e..7fa99625 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -1,5 +1,12 @@ import type { StateCode } from "@/data/areas"; +/** Site contact row; the first entry in `contacts` is the Main contact. */ +export interface LocationContact { + id?: number; + name: string; + phone: string; +} + export interface LocationListItem { id: string | number; name: string; @@ -12,6 +19,8 @@ export interface LocationListItem { contactEmail: string; status: string; accountId: string | number; + /** Additive contacts payload; `contact`/`phone` mirror the first entry. */ + contacts?: LocationContact[]; } export interface Location { @@ -26,6 +35,8 @@ export interface Location { contactEmail?: string; status?: string; accountId?: string | number; + /** Additive contacts payload; the first entry is the Main contact. */ + contacts?: LocationContact[]; } /** Site option from GET locations/sites (wizard / board create). */ diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 5afbc13e..fda4215e 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -76,6 +76,24 @@ export const vendorsApi = { }; }, + // SH-281: grouped company rows come from the dedicated directory endpoint; + // Vendor/GetVendorList stays on the legacy technician-level contract. + getDirectoryList: async (params: VendorsListParams = {}): Promise => { + const data = await apiGet(API_PATHS.vendor.getDirectoryList, { + searchParams: buildListParams(params), + }); + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + return { + items: mapVendorListItems(paginated.items), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + getFacets: async (params?: VendorFacetsParams): Promise => { const data = await apiGet(API_PATHS.vendor.facets, { searchParams: buildFacetsParams(params), diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 321e4542..8514dc2d 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -1,6 +1,7 @@ import type { Vendor, VendorDeactivationImpact, + VendorDirectoryTechnician, VendorFacetCompany, VendorFacetJobBucket, VendorFacetLocation, @@ -108,7 +109,7 @@ function readBool(record: Record, ...keys: string[]): boolean { export function mapVendorListItem(raw: unknown): VendorListItem { const item = asRecord(raw); - return { + const mapped = { id: readId(item), companyId: readCompanyId(item), companyName: readString(item, "companyName", "CompanyName"), @@ -126,6 +127,40 @@ export function mapVendorListItem(raw: unknown): VendorListItem { isActive: readBool(item, "isActive", "IsActive"), preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), }; + const rawTechnicians = item.technicians ?? item.Technicians; + const technicians = Array.isArray(rawTechnicians) + ? rawTechnicians.map(mapVendorDirectoryTechnician) + : mapped.id == null + ? [] + : [ + { + id: mapped.id, + contactName: mapped.contactName, + email: mapped.email, + phone: mapped.phone, + preferredContact: mapped.preferredContact, + tradeSpecialties: mapped.tradeSpecialties, + isActive: mapped.isActive, + totalJobs: mapped.totalJobs, + }, + ]; + + return { ...mapped, technicians }; +} + +function mapVendorDirectoryTechnician(raw: unknown): VendorDirectoryTechnician { + const item = asRecord(raw); + const id = readId(item); + return { + id: id ?? "", + contactName: readString(item, "contactName", "ContactName"), + email: readString(item, "email", "Email"), + phone: readString(item, "phone", "Phone"), + preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), + tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), + isActive: readBool(item, "isActive", "IsActive"), + totalJobs: readNumber(item, "totalJobs", "TotalJobs"), + }; } export function mapVendorListItems(rawItems: unknown[]): VendorListItem[] { diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index b6ee694d..d7747afc 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -2,6 +2,17 @@ export type VendorPreferredContact = "Phone" | "Email" | "Text"; export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"]; +export interface VendorDirectoryTechnician { + id: string | number; + contactName: string; + email: string; + phone: string; + preferredContact: VendorPreferredContact; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +} + export interface VendorListItem { id: string | number | null; companyId: string | number | null; @@ -19,6 +30,8 @@ export interface VendorListItem { tradeSpecialties: string; isActive: boolean; preferredContact: VendorPreferredContact; + /** Present on company-grouped directory responses; optional for legacy clients/fixtures. */ + technicians?: VendorDirectoryTechnician[]; } export interface Vendor { diff --git a/src/domain/vendors/use-cases/use-vendor-directory-list.ts b/src/domain/vendors/use-cases/use-vendor-directory-list.ts new file mode 100644 index 00000000..b610af63 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-directory-list.ts @@ -0,0 +1,14 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import type { VendorsListParams, VendorsListResult } from "@/domain/vendors/types/vendor"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useVendorDirectoryList( + params: VendorsListParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.vendors.directoryList(params as Record), + queryFn: () => vendorsApi.getDirectoryList(params), + placeholderData: keepPreviousData, + }); +} diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts index 4a9a4c72..e522356f 100644 --- a/src/domain/work-orders/utils/board-audit-action-rules.ts +++ b/src/domain/work-orders/utils/board-audit-action-rules.ts @@ -39,24 +39,53 @@ function formatSeverityAuditLabel(raw: string): string { return raw; } -function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { +const CLEARED_AUDIT_VALUE: FormattedAuditValue = { text: "—", isQuotedList: false }; + +/** `from X to Y` whenever the previous value is known; a cleared value reads as "—". */ +function formatFieldChangeSentence( + label: string, + oldValue: FormattedAuditValue, + newValue: FormattedAuditValue, +): string | null { + if (!oldValue.text && !newValue.text) return null; + const next = wrapAuditValue(newValue.text ? newValue : CLEARED_AUDIT_VALUE); + if (oldValue.text) { + return `Changed ${label} from ${wrapAuditValue(oldValue)} to ${next}`; + } + return `Changed ${label} to ${next}`; +} + +function tryTypeRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { const matches = fieldKey === "type" || fieldKey === "workordertype" || fieldKey === "wotype" || actionKey === "typechanged" || actionKey.includes("workordertype"); - if (!matches || !newValue.text) return null; + if (!matches) return null; - return `Changed Type to ${wrapAuditValue(newValue)}`; + return formatFieldChangeSentence("Type", oldValue, newValue); } -function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { - const matches = fieldKey.includes("severity") || actionKey.includes("severity"); - if (!matches || !newValue.text) return null; +function severityValue(value: FormattedAuditValue): FormattedAuditValue { + return value.text ? { text: formatSeverityAuditLabel(value.text), isQuotedList: false } : value; +} - const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false }; - return `Changed Severity to ${wrapAuditValue(labeled)}`; +function trySeverityRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { + const matches = fieldKey.includes("severity") || actionKey.includes("severity"); + if (!matches) return null; + + return formatFieldChangeSentence("Severity", severityValue(oldValue), severityValue(newValue)); } function tryStatusRule({ diff --git a/src/domain/work-orders/utils/due-date-floor.ts b/src/domain/work-orders/utils/due-date-floor.ts new file mode 100644 index 00000000..95f8eb54 --- /dev/null +++ b/src/domain/work-orders/utils/due-date-floor.ts @@ -0,0 +1,27 @@ +/** + * SH-356: a Due Date can never be earlier than Schedule On. + * + * The floor is the concrete Schedule On day, the start of a date range, or — for a + * week-only schedule (SH-110) — the first day of the target week. + */ +export function resolveDueDateFloor(schedule: { + scheduledOn?: string | null; + targetWeek?: string | null; +}): string { + return schedule.scheduledOn?.trim() || schedule.targetWeek?.trim() || ""; +} + +export function isDueDateBeforeFloor(dueDate: string | null | undefined, floor: string): boolean { + return Boolean(dueDate && floor && dueDate < floor); +} + +/** + * Patch fragment for a Schedule On change: clears an existing Due Date that the new + * schedule would leave in the past. Never bumps it to a guessed value. + */ +export function clearDueDateBeforeSchedule( + dueDate: string | null | undefined, + nextFloor: string, +): { dueDate?: string } { + return isDueDateBeforeFloor(dueDate, nextFloor) ? { dueDate: "" } : {}; +} diff --git a/src/domain/work-orders/utils/format-audit-timestamp.ts b/src/domain/work-orders/utils/format-audit-timestamp.ts new file mode 100644 index 00000000..4d987df4 --- /dev/null +++ b/src/domain/work-orders/utils/format-audit-timestamp.ts @@ -0,0 +1,24 @@ +const AUDIT_DATE_PATTERN = /\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/; + +/** + * Every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`. + * Pre-formatted API strings are kept only when they already carry a calendar date. + */ +export function formatAuditTimestamp(value: string): string { + if (!value) return ""; + if (AUDIT_DATE_PATTERN.test(value) && /\b(AM|PM)\b/i.test(value)) return value; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + const datePart = date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + const timePart = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); + return `${datePart} · ${timePart}`; +} diff --git a/src/domain/work-orders/utils/format-board-audit-action.ts b/src/domain/work-orders/utils/format-board-audit-action.ts index 05ebe168..e29cbcbd 100644 --- a/src/domain/work-orders/utils/format-board-audit-action.ts +++ b/src/domain/work-orders/utils/format-board-audit-action.ts @@ -127,6 +127,10 @@ function formatGenericAuditFallback(args: { if (newValue.text) { return `Changed ${label} to ${wrapAuditValue(newValue)}`; } - if (action) return action; + if (oldValue.text) { + return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`; + } + // Never surface a raw audit enum such as "FieldChanged" as the sentence. + if (fieldName && action) return `Updated ${label}`; return "Updated work order"; } diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts index 5de1e4ad..7092c696 100644 --- a/src/domain/work-orders/utils/partition-slide-over-media.ts +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -12,6 +12,11 @@ const EMPTY_OWNERSHIP: MediaSurfaceOwnership = { extraOwnedIds: new Set(), }; +/** Only Reactive and Emergency work orders render a media gallery on the Completion tab. */ +export function hasCompletionMediaGallery(type: string | null | undefined): boolean { + return type === "Emergency" || type === "Reactive"; +} + function isPhotoOrVideo(item: MediaFile): boolean { return item.fileType === "image" || item.fileType === "video"; } @@ -24,20 +29,25 @@ function isInFlight(item: MediaFile): boolean { export function isCompletionSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (isInFlight(item)) return true; if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true; + if (!completionHasGallery && item.purpose === "Extra") return false; if (ownership.completionOwnedIds.has(item.id)) return true; if (ownership.extraOwnedIds.has(item.id)) return false; return item.purpose === "Extra" && isPhotoOrVideo(item); } +/** Without a Completion gallery, every Extra item (including photos/videos) belongs to Extras. */ export function isExtrasSurfaceMedia( item: MediaFile, ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, + completionHasGallery = true, ): boolean { if (item.purpose === "Aveta") return true; if (item.purpose !== "Extra") return false; + if (!completionHasGallery) return true; if (ownership.completionOwnedIds.has(item.id)) return false; if (ownership.extraOwnedIds.has(item.id)) return true; return !isPhotoOrVideo(item); diff --git a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts index 264344eb..f0184ef9 100644 --- a/src/domain/work-orders/utils/wo-lifecycle-transitions.ts +++ b/src/domain/work-orders/utils/wo-lifecycle-transitions.ts @@ -11,8 +11,21 @@ import { UPLIFT_PENDING_COMPLETED_TITLE, } from "@/domain/work-orders/utils/wo-closability"; -/** AAP `ALL_STATUSES` — Canceled is kebab-only. */ -export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES; +/** + * Lifecycle states the server derives (Incomplete from create, Scheduled from Schedule On). + * Past Due is an overlay, not a status. None of these may be picked manually on an existing WO. + */ +export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"]; + +/** Manually selectable statuses for an existing WO — Canceled is kebab-only. */ +export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter( + (status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status), +); + +/** Inline options for an existing WO never repeat its current status. */ +export function getInlineStatusTransitions(currentStatus: string): WizardWOStatus[] { + return BOARD_INLINE_STATUSES.filter((status) => status !== currentStatus); +} /** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */ export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [ diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 36915f91..c4a9e284 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -68,6 +68,8 @@ export const queryKeys = { all: ["vendors"] as const, list: (params?: Record) => [...queryKeys.vendors.all, "list", params ?? {}] as const, + directoryList: (params?: Record) => + [...queryKeys.vendors.all, "directoryList", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const, facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const, dropdown: (trade?: string, siteZip?: string) => diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx new file mode 100644 index 00000000..60444e29 --- /dev/null +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -0,0 +1,178 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LocationContactsFieldArray } from "@/app/(protected)/locations/_components/location-contacts-field-array"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +const dataTransferStub = { + setData: vi.fn(), + effectAllowed: "none" as DataTransfer["effectAllowed"], +}; + +function ContactsHarness({ + initialContacts, + onSubmit = vi.fn(), +}: { + initialContacts: LocationFormValues["contacts"]; + onSubmit?: (values: LocationFormValues) => void; +}) { + const { + control, + handleSubmit, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", + contacts: initialContacts, + }, + }); + + return ( +
+ + + + ); +} + +function nameInputs(): HTMLInputElement[] { + return screen.getAllByLabelText(/^Contact Name/) as HTMLInputElement[]; +} + +/** 1-based handle position: `handleFor(2)` is the second row's drag handle. */ +function handleFor(position: number): HTMLElement { + return screen.getByRole("button", { + name: `Reorder contact ${position}. Use arrow up or down to move.`, + }); +} + +describe("LocationContactsFieldArray", () => { + it("marks the first row as Main and disables removal when only one remains", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Main")).toBeInTheDocument(); + expect(screen.getByLabelText(/^Contact Name \(Main\)/)).toHaveValue("Jane"); + expect(screen.getByLabelText("Remove contact 1")).toBeDisabled(); + }); + + it("adds and removes contact rows", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + + expect(nameInputs()).toHaveLength(2); + expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); + expect(screen.getByText("Main")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Remove contact 2")); + expect(nameInputs()).toHaveLength(1); + expect(screen.getByLabelText("Remove contact 1")).toBeDisabled(); + }); + + it("reorders with keyboard ArrowUp and keeps focus on the moved handle", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(nameInputs().map((input) => input.value)).toEqual(["Jane", "Bob"]); + + const bobHandle = handleFor(2); + bobHandle.focus(); + fireEvent.keyDown(bobHandle, { key: "ArrowUp" }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Bob", "Jane"]); + await waitFor(() => { + expect(document.activeElement).toBe(handleFor(1)); + }); + }); + + it("ignores ArrowDown on the last row", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.keyDown(handleFor(2), { key: "ArrowDown" }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Jane", "Bob"]); + }); + + it("reorders with native drag events from the handle", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.dragStart(handleFor(1), { dataTransfer: dataTransferStub }); + fireEvent.drop(nameInputs()[1].closest("div") as HTMLElement, { + dataTransfer: dataTransferStub, + }); + + expect(nameInputs().map((input) => input.value)).toEqual(["Bob", "Jane"]); + }); + + it("submits contacts in displayed order", async () => { + const onSubmit = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.keyDown(handleFor(2), { key: "ArrowUp" }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + expect(onSubmit.mock.calls[0][0]).toEqual( + expect.objectContaining({ + contacts: [ + { id: 12, name: "Bob", phone: "(421) 433-0033" }, + { id: 11, name: "Jane", phone: "(421) 433-0032" }, + ], + }), + ); + }); +}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx new file mode 100644 index 00000000..e0258626 --- /dev/null +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { LocationsContactsCell } from "@/app/(protected)/locations/_components/locations-contacts-cell"; +import type { LocationListItem } from "@/domain/locations/types/location"; +import { renderWithProviders } from "@/test/test-utils"; + +function rowWithContacts(overrides: Partial): LocationListItem { + return { + id: 1, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + accountId: 7, + ...overrides, + }; +} + +describe("LocationsContactsCell", () => { + it("renders only the main contact name when a single contact exists", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Jane")).toBeInTheDocument(); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it("shows a +N chip and lists remaining contact names on hover", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + const chip = screen.getByText("+2"); + expect(chip).toBeInTheDocument(); + expect(screen.getByLabelText("2 more contacts: Bob, Carol")).toBeInTheDocument(); + + fireEvent.mouseOver(chip); + expect(await screen.findByText("Bob, Carol")).toBeInTheDocument(); + }); + + it("falls back to the legacy contact field without a chip", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Jane")).toBeInTheDocument(); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it("renders a placeholder when no contact exists", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index e1bc87d5..68c3fa51 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -27,8 +27,7 @@ function StateSelectHarness({ address: "3811 Distribution Dr", city: "Dallas", zipCode: "70068", - phone: "(421) 433-0032", - contact: "Jane", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", status: "Active", }, diff --git a/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx b/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx new file mode 100644 index 00000000..986f41ad --- /dev/null +++ b/src/test/app/(protected)/vendor-pos/vendor-pos-legacy-vendors-list.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import VendorPosListPage from "@/app/(protected)/vendor-pos/index"; +import { renderWithProviders } from "@/test/test-utils"; + +const useVendorPosList = vi.fn(); +const useVendorsList = vi.fn(); +const useVendorDirectoryList = vi.fn(); + +vi.mock("@/domain/vendor-pos/use-cases/use-vendor-pos-list", () => ({ + useVendorPosList: (...args: unknown[]) => useVendorPosList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ + useVendorsList: (...args: unknown[]) => useVendorsList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-directory-list", () => ({ + useVendorDirectoryList: (...args: unknown[]) => useVendorDirectoryList(...args), +})); + +describe("VendorPosListPage vendor source", () => { + it("keeps the legacy technician-level vendors list with pageSize 500", () => { + useVendorPosList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }); + useVendorsList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 500 }, + isLoading: false, + isFetching: false, + error: null, + }); + useVendorDirectoryList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 500 }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderWithProviders(, { route: "/vendor-pos", withAuth: false }); + + expect(useVendorsList).toHaveBeenCalledWith({ page: 1, pageSize: 500 }); + expect(useVendorsList).toHaveBeenCalledTimes(1); + expect(useVendorDirectoryList).not.toHaveBeenCalled(); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index dd0070b8..ab905729 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -5,12 +5,12 @@ import VendorsListPage from "@/app/(protected)/vendors/index"; import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; -const useVendorsList = vi.fn(); +const useVendorDirectoryList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); -vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ - useVendorsList: (...args: unknown[]) => useVendorsList(...args), +vi.mock("@/domain/vendors/use-cases/use-vendor-directory-list", () => ({ + useVendorDirectoryList: (...args: unknown[]) => useVendorDirectoryList(...args), })); vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ @@ -108,15 +108,22 @@ function setupDefaults() { describe("VendorsListPage", () => { it("renders prototype-aligned status and two row actions in both tabs", async () => { setupDefaults(); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); - return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); + return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); - expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Company Phone" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Company Email" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Street Address" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Technician Phone" })).toBeInTheDocument(); + expect(screen.getByText("St. Louis")).toBeInTheDocument(); + expect(screen.getByText("MO")).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); expect( @@ -134,7 +141,58 @@ describe("VendorsListPage", () => { expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); - it("prefers trimmed city and state over address and technician phone over company phone", () => { + it("renders one company row with the primary technician and a keyboard-accessible roster badge", async () => { + setupDefaults(); + const groupedVendor: VendorListItem = { + ...activeVendor, + id: 22, + contactName: "Promoted Technician", + totalJobs: 9, + technicians: [ + { + id: 11, + contactName: "Older Technician", + email: "older@gateway.test", + phone: "314-555-0111", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 4, + }, + { + id: 22, + contactName: "Promoted Technician", + email: "promoted@gateway.test", + phone: "314-555-0222", + preferredContact: "Email", + tradeSpecialties: "HVAC", + isActive: true, + totalJobs: 5, + }, + ], + }; + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([groupedVendor], 1) : result([], 0); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getAllByText("Gateway Plumbing")).toHaveLength(1); + expect(screen.getByText("Promoted Technician")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "314-555-0222" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "9" })).toBeInTheDocument(); + + const rosterBadge = screen.getByTestId("vendor-technician-badge"); + expect(rosterBadge).toHaveTextContent("+1"); + expect(rosterBadge).toHaveAttribute("tabindex", "0"); + await userEvent.hover(rosterBadge); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Older Technician"); + }); + + it("renders company location and contact fields in separate columns", () => { setupDefaults(); const vendorWithAllFields: VendorListItem = { ...activeVendor, @@ -144,21 +202,29 @@ describe("VendorsListPage", () => { state: " MO ", phone: " (314) 555-0198 ", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([vendorWithAllFields], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([vendorWithAllFields], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.queryByText(/1 Market St/)).not.toBeInTheDocument(); - const phoneLink = screen.getByRole("link", { name: "(314) 555-0198" }); - expect(phoneLink).toHaveAttribute("href", "tel:(314) 555-0198"); - expect(screen.queryByText("(314) 555-0100")).not.toBeInTheDocument(); + expect(screen.getByText("1 Market St")).toBeInTheDocument(); + expect(screen.getByText("St. Louis")).toBeInTheDocument(); + expect(screen.getByText("MO")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "(314) 555-0198" })).toHaveAttribute( + "href", + "tel:(314) 555-0198", + ); + expect(screen.getByRole("link", { name: "(314) 555-0100" })).toHaveAttribute( + "href", + "tel:(314) 555-0100", + ); }); - it("falls back to the trimmed address and company phone when technician fields are absent", () => { + it("keeps company and technician phone ownership separate when technician phone is absent", () => { setupDefaults(); const fallbackVendor: VendorListItem = { ...activeVendor, @@ -168,10 +234,12 @@ describe("VendorsListPage", () => { state: "", phone: " ", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([fallbackVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -179,6 +247,7 @@ describe("VendorsListPage", () => { const companyPhoneLink = screen.getByRole("link", { name: "(314) 555-0100" }); expect(companyPhoneLink).toHaveAttribute("href", "tel:(314) 555-0100"); expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument(); + expect(screen.getByTestId("vendor-text-technician-phone")).toHaveTextContent("—"); }); it("truncates every textual table field and reveals each full value", async () => { @@ -199,19 +268,21 @@ describe("VendorsListPage", () => { city: "", state: "", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([fallbackVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); const fields = [ { testId: "vendor-text-company", value: longCompany }, { testId: "vendor-text-technician", value: longTechnician }, - { testId: "vendor-text-location", value: longAddress }, - { testId: "vendor-text-phone", value: longPhone }, - { testId: "vendor-text-email", value: longEmail }, + { testId: "vendor-text-street-address", value: longAddress }, + { testId: "vendor-text-technician-phone", value: longPhone }, + { testId: "vendor-text-company-email", value: longEmail }, ]; for (const field of fields) { @@ -231,10 +302,18 @@ describe("VendorsListPage", () => { await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); } - expect(screen.getByTestId("vendor-text-phone")).toHaveAttribute("href", `tel:${longPhone}`); - expect(screen.getByTestId("vendor-text-email")).toHaveAttribute("href", `mailto:${longEmail}`); + expect(screen.getByTestId("vendor-text-technician-phone")).toHaveAttribute( + "href", + `tel:${longPhone}`, + ); + expect(screen.getByTestId("vendor-text-company-email")).toHaveAttribute( + "href", + `mailto:${longEmail}`, + ); + fireEvent.keyDown(screen.getByTestId("vendor-text-company-email"), { key: "Enter" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - const locationText = screen.getByTestId("vendor-text-location"); + const locationText = screen.getByTestId("vendor-text-street-address"); fireEvent.keyDown(document, { key: "Tab" }); locationText.focus(); expect(locationText).toHaveFocus(); @@ -250,10 +329,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -277,10 +358,12 @@ describe("VendorsListPage", () => { it("does not show the +N badge when the vendor has a single trade", () => { setupDefaults(); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -301,10 +384,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -325,10 +410,12 @@ describe("VendorsListPage", () => { ...activeVendor, tradeSpecialties: "Backflow Preventers, Water Heaters", }; - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([multiTradeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([multiTradeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -345,15 +432,17 @@ describe("VendorsListPage", () => { companyName: `Vendor ${index + 1}`, })); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); - return result(pageVendors, 25); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); + return result(pageVendors, 25); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/25 active companies/)).not.toHaveLength(0); + expect(screen.queryByText(/3 active companies/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { @@ -365,10 +454,12 @@ describe("VendorsListPage", () => { error: null, refetch: vi.fn(), }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -406,10 +497,12 @@ describe("VendorsListPage", () => { isLoading: false, error: null, }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -447,10 +540,12 @@ describe("VendorsListPage", () => { isLoading: false, error: null, }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -477,10 +572,12 @@ describe("VendorsListPage", () => { isLoading: false, error: null, }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -508,10 +605,12 @@ describe("VendorsListPage", () => { error: null, refetch: vi.fn(), }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -533,10 +632,12 @@ describe("VendorsListPage", () => { error: null, refetch: vi.fn(), }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); - }); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); + }, + ); renderWithProviders(, { route: "/vendors", withAuth: false }); @@ -547,4 +648,24 @@ describe("VendorsListPage", () => { "Gateway Plumbing", ); }); + + it("loads rows and status counts through the directory hook", async () => { + setupDefaults(); + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 3 : 2); + return result([activeVendor], 3); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(useVendorDirectoryList).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, pageSize: 48 }), + ); + expect(useVendorDirectoryList).toHaveBeenCalledWith({ isActive: true, page: 1, pageSize: 1 }); + expect(useVendorDirectoryList).toHaveBeenCalledWith({ isActive: false, page: 1, pageSize: 1 }); + expect(screen.getByRole("tab", { name: "Active (3)" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Inactive (2)" })).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx new file mode 100644 index 00000000..e6c41d50 --- /dev/null +++ b/src/test/app/(protected)/workorders/due-date-schedule-floor.test.tsx @@ -0,0 +1,194 @@ +import { fireEvent, render, renderHook, screen, within } 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 { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({ + SiteCell: () => null, +})); +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/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, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({ + rescheduleNotify: vi.fn(), + weekMoveNotify: vi.fn(), +})); + +function day(n: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(n)}`; +} + +function rowByLabel(label: string) { + return screen.getByText(label, { selector: "span,label,div" }).closest("div")!; +} + +describe("Due Date cannot be before Schedule On (SH-356)", () => { + it("disables calendar days before the floor", () => { + render(); + + expect(screen.getByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "11" })).toBeEnabled(); + }); + + describe("slide-over edit", () => { + const draft = { scheduledOn: day(10), dueDate: day(12) } as Partial; + + function renderSlideOver(onDraftChange = vi.fn()) { + renderWithProviders( + , + ); + return onDraftChange; + } + + it("disables Due Date days before Schedule On", async () => { + renderSlideOver(); + fireEvent.click(within(rowByLabel("Due Date")).getByRole("button")); + + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "10" })).toBeEnabled(); + }); + + it("clears the Due Date when Schedule On moves past it", async () => { + const onDraftChange = renderSlideOver(); + fireEvent.click(within(rowByLabel("Schedule On")).getAllByRole("button")[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + }); + + it("wizard: clears Due Date on a later Schedule On and floors the Due Date picker", async () => { + const onPatch = vi.fn(); + render( + , + ); + + const dueDateButtons = within(rowByLabel("Due Date").parentElement!).getAllByRole("button"); + fireEvent.click(dueDateButtons[dueDateButtons.length - 1]); + expect(await screen.findByRole("button", { name: "9" })).toBeDisabled(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + + const scheduleBlock = rowByLabel("Schedule On").parentElement!; + const scheduleButtons = within(scheduleBlock).getAllByRole("button"); + fireEvent.click(scheduleButtons[scheduleButtons.length - 1]); + const calendars = await screen.findAllByRole("button", { name: "15" }); + fireEvent.click(calendars[calendars.length - 1]); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("inline creation row: clears Due Date on a later Schedule On", async () => { + const onChange = vi.fn(); + renderWithProviders( +
+ {children} + + onPatch({ type: t, severity: sev })} /> - - — + +
+ — +
+ onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, date), scheduledOn: date, scheduleWeekOnly: false, targetWeek: "", @@ -105,6 +123,7 @@ function InlineScheduleOnCell({ onSaveWeek={(week) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, week), scheduledOn: "", targetWeek: week, scheduleWeekOnly: true, @@ -117,6 +136,7 @@ function InlineScheduleOnCell({ onSaveRange={(start, end) => onPatch( withAutoScheduleStatus(draft, { + ...clearDueDateBeforeSchedule(draft.dueDate, start), scheduledOn: start, scheduleEnd: end, schedMode: "range", @@ -142,7 +162,7 @@ function InlineScheduleOnCell({ : undefined } /> - + { onPatch({ dispatcherId: id, status: draft.status || "Incomplete" }); }} /> - + onPatch({ dueDate: date })} /> - + + - + + onPatch(toVendorTablePatch(p))} /> - + + - + + {}} onRequestComplete={onRequestComplete} /> - + - - +
+
+ handlers.onScheduleDate(row.id, date)} - onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)} - onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)} + onSave={(date) => handlers.onScheduleDate(row.id, date, row.dueDate)} + onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week, row.dueDate)} + onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end, row.dueDate)} onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined} />
+ + + +
, + { withAuth: false }, + ); + + const cells = screen.getAllByRole("cell"); + const dateButtons = cells.flatMap((cell) => within(cell).queryAllByRole("button")); + const scheduleButton = dateButtons.find( + (b) => b.textContent?.includes(day(10).slice(5).replace("-", "/")) ?? false, + ); + fireEvent.click(scheduleButton ?? dateButtons[0]); + fireEvent.click(await screen.findByRole("button", { name: "15" })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ scheduledOn: day(15), dueDate: "" }), + ); + }); + + it("board row: sends the Due Date clear in the same schedule patch", () => { + const onPatch = vi.fn(); + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: { + dragState: { current: { dragId: null, overId: null } }, + setDragViz: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + revealScheduledRow: vi.fn(), + setSelectedId: vi.fn(), + } as never, + weekMonday: "2026-05-18", + onNavigateToDay: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onScheduleDate("wo-1", "2026-05-21", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith("wo-1", expect.objectContaining({ dueDate: "" })); + + result.current.onScheduleRange("wo-1", "2026-05-19", "2026-05-22", "2026-05-20"); + expect(onPatch.mock.lastCall?.[1]).not.toHaveProperty("dueDate"); + + result.current.onScheduleWeek("wo-1", "2026-05-25", "2026-05-20"); + expect(onPatch).toHaveBeenLastCalledWith( + "wo-1", + expect.objectContaining({ targetWeek: "2026-05-25", dueDate: "" }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 0ca3f3c9..86b6020c 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -42,6 +42,16 @@ describe("saveExistingRowType", () => { expect(onPatch.mock.calls[0]![2]).toBeUndefined(); }); + it("does not warn when moving between SEV 3–5", () => { + const onPatch = vi.fn(); + + saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3); + + expect(toastWarn).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 }); + expect(onPatch.mock.calls[0]![2]).toBeUndefined(); + }); + it("does not warn when clearing severity on a PM correction", () => { const onPatch = vi.fn(); diff --git a/src/test/app/(protected)/workorders/severity-badge.test.tsx b/src/test/app/(protected)/workorders/severity-badge.test.tsx new file mode 100644 index 00000000..2beb69ef --- /dev/null +++ b/src/test/app/(protected)/workorders/severity-badge.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SeverityBadge } from "@/app/(protected)/workorders/_components/list/table/cells/severity-badge"; + +describe("SeverityBadge", () => { + it("shows description, Accept/Respond deadline, and after-hours on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 1")); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent( + "Business Critical Function Down · Accept: 15 min · Respond: 2 hrs · After hours: Yes", + ); + expect(tooltip).not.toHaveTextContent(/Quote|Tech Notes|Invoice|Turnaround/i); + + await user.unhover(screen.getByText("SEV 1")); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + }); + + it("transcribes the SEV table row for the given severity", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByText("SEV 3")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Group Productivity Impaired · Accept: 2 hrs · Respond: 8 hrs (next day if after hours) · After hours: Approval required", + ); + }); + + it("is a button only when it can reopen the severity control", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + rerender(); + const badge = screen.getByRole("button"); + expect(badge).toHaveTextContent("SEV 2"); + await user.click(badge); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx b/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx new file mode 100644 index 00000000..ac8d7393 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list"; +import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order"; + +function statusEntry(overrides: Partial = {}): WorkOrderAuditEntry { + return { + id: "s1", + fieldName: "LifecycleStatus", + oldValue: "Pending", + newValue: "En Route", + action: "StatusChanged", + createdAt: "2026-09-15T19:42:00", + userName: "Alex Dispatcher", + type: "manual", + ...overrides, + }; +} + +describe("SlideOverAuditList timestamps (SH-361)", () => { + it("shows the date as well as the time on a manual status change", () => { + render(); + + expect(screen.getByText('Changed status from "Pending" to "En Route"')).toBeInTheDocument(); + expect(screen.getByText(/^\d{2}\/\d{2}\/2026 · 7:42 PM$/)).toBeInTheDocument(); + }); + + it("renders a type correction with its previous value (SH-363)", () => { + render( + , + ); + + expect(screen.getByText('Changed Type from "PM" to "Reactive"')).toBeInTheDocument(); + expect(screen.getByText('Changed Severity from "SEV 3" to "—"')).toBeInTheDocument(); + expect(screen.queryByText("FieldChanged")).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index f788e250..ba674966 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -161,4 +161,40 @@ describe("SlideOverExtrasTab Aveta", () => { expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled(); }); + + it("labels each extra item with its type and category", () => { + const sitePhoto: MediaFile = { + id: "m3", + name: "site.jpg", + fileType: "image", + url: "blob:3", + category: "Photo", + purpose: "Extra", + }; + const walkthrough: MediaFile = { + id: "m4", + name: "walkthrough.mp4", + fileType: "video", + url: "blob:4", + category: "Video", + purpose: "Extra", + }; + + renderWithProviders( + , + ); + + expect(screen.getByRole("group", { name: "Photo, Extra: site.jpg" })).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Video, Extra: walkthrough.mp4" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "PDF, Aveta: aveta-clearance.pdf" }), + ).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx index 31c90363..7f42d257 100644 --- a/src/test/app/(protected)/workorders/status-cell-closability.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-closability.test.tsx @@ -1,6 +1,7 @@ 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 { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; const incompleteRow = { @@ -43,21 +44,62 @@ describe("StatusCell closability gate", () => { expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull(); }); - it("disables Scheduled when the row has no concrete date", () => { + it.each([ + "Pending", + "En Route", + "On Site", + "In Progress", + "Rescheduled", + "Pending Quote", + ])("offers only manual transitions away from %s (SH-357, SH-358)", (current) => { const onChangeStatus = vi.fn(); render( , ); - fireEvent.click(screen.getByRole("button", { name: /pending/i })); - const scheduled = screen.getByRole("button", { name: /^scheduled$/i }); - expect(scheduled).toBeDisabled(); - fireEvent.click(scheduled); - expect(onChangeStatus).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") })); + const offered = Array.from(document.querySelectorAll(".MuiPopover-paper button")).map( + (button) => button.textContent?.trim(), + ); + + expect(offered).not.toContain("Incomplete"); + expect(offered).not.toContain("Scheduled"); + expect(offered).not.toContain("Past Due"); + expect(offered).not.toContain("Canceled"); + expect(offered).not.toContain(current); + expect(offered).toEqual( + [ + "Pending", + "En Route", + "On Site", + "In Progress", + "Completed", + "Rescheduled", + "Pending Quote", + ].filter((status) => status !== current), + ); + }); + + it("offers every manual status, and no automatic one, from Incomplete or Scheduled", () => { + for (const current of ["Incomplete", "Scheduled"] as const) { + const { unmount } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") })); + expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^incomplete$/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull(); + unmount(); + } }); it("limits creation options to Incomplete, Pending, and Scheduled", () => { diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 87a74de5..dab9e3bd 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -278,4 +278,53 @@ describe("useSlideOverMediaState completion filter", () => { expect(remounted.result.current.completionMedia).toHaveLength(0); expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); }); + + it("shows Extra photos and videos in Extra Docs on PM work orders", () => { + const detail = detailWithMedia( + [ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "11", + category: "Extra", + url: "https://cdn.example.com/walkthrough.mp4", + fileName: "walkthrough.mp4", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + { + id: "4", + category: "Extra", + url: "https://cdn.example.com/extra.pdf", + fileName: "extra.pdf", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ], + { id: 200, type: "PM" }, + ); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10", "11", "4"]); + expect(result.current.extrasUploads.map((item) => item.fileType)).toEqual([ + "image", + "video", + "pdf", + ]); + }); }); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx new file mode 100644 index 00000000..f5817e84 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-inline-row-required-fields.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields"; +import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row"; +import { + EMPTY_TABLE_DRAFT, + type WorkOrderTableDraft, +} from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +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/due-date-cell", () => ({ + DueDateCell: () => 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/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); + +function renderInlineRow(draft: WorkOrderTableDraft, onSave = vi.fn()) { + const ui = (d: WorkOrderTableDraft) => ( + + + + +
+ ); + const view = renderWithProviders(ui(draft), { withAuth: false }); + return { ...view, rerenderDraft: (d: WorkOrderTableDraft) => view.rerender(ui(d)) }; +} + +const COMPLETE_DRAFT: WorkOrderTableDraft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: 9, + woNumber: "12345678901", + pocName: "Dana", + pocPhone: "555-0100", +}; + +describe("WoTableInlineRow required fields", () => { + it("marks Site and WO # as required without showing errors before a Save attempt", () => { + renderInlineRow(EMPTY_TABLE_DRAFT); + + expect(screen.getByRole("button", { name: /^Site \*$/ })).toBeInTheDocument(); + expect(screen.getByText("Insert WO #")).toHaveTextContent("Insert WO # *"); + expect(screen.queryByText("Site is required")).not.toBeInTheDocument(); + expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument(); + }); + + it("reveals field errors only after Save is attempted, then clears them once corrected", () => { + const onSave = vi.fn(); + const { rerenderDraft } = renderInlineRow(EMPTY_TABLE_DRAFT, onSave); + + fireEvent.click(screen.getByRole("button", { name: "Save work order" })); + + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText("Site is required")).toBeInTheDocument(); + expect(screen.getByText("Work order number is required")).toBeInTheDocument(); + + rerenderDraft(COMPLETE_DRAFT); + + expect(screen.queryByText("Site is required")).not.toBeInTheDocument(); + expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save work order" })); + expect(onSave).toHaveBeenCalledTimes(1); + }); +}); + +describe("PocContactFields required primary contact", () => { + it("labels POC name and phone with a required asterisk when the contact is required", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByLabelText(/POC name/)).toHaveAttribute("placeholder", "POC name"); + expect(screen.getByLabelText(/POC phone/)).toHaveAttribute("placeholder", "POC phone"); + expect(screen.getByText("POC name").closest("label")).toHaveTextContent("POC name*"); + expect(screen.getByText("POC phone").closest("label")).toHaveTextContent("POC phone*"); + expect(screen.queryByText("Contact name is required")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts new file mode 100644 index 00000000..52d27cc6 --- /dev/null +++ b/src/test/domain/locations/mappers/location-mapper.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + mapLocation, + mapLocationListItem, + mapLocationToBackend, +} from "@/domain/locations/mappers/location-mapper"; + +describe("location contacts hydration", () => { + it("maps the response contacts array and derives the main contact fields", () => { + const item = mapLocationListItem({ + id: 1, + name: "Dallas DC", + contacts: [ + { name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ], + }); + + expect(item.contacts).toEqual([ + { name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ]); + expect(item.contact).toBe("Jane"); + expect(item.phone).toBe("(421) 433-0032"); + }); + + it("falls back to legacy contact/phone fields when no contacts array exists", () => { + const item = mapLocationListItem({ + id: 2, + contact: "Bob", + phone: "(312) 555-0100", + }); + + expect(item.contacts).toBeUndefined(); + expect(item.contact).toBe("Bob"); + expect(item.phone).toBe("(312) 555-0100"); + }); + + it("hydrates detail contacts and mirrors the first row in contact/phone", () => { + const detail = mapLocation({ + id: 3, + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + + expect(detail.contacts).toEqual([{ id: 31, name: "Carol", phone: "555-0143" }]); + expect(detail.contact).toBe("Carol"); + expect(detail.phone).toBe("555-0143"); + }); + + it("keeps legacy detail contact/phone for zero-contact data", () => { + const detail = mapLocation({ id: 4, contact: "Legacy Person", phone: "555-0199" }); + + expect(detail.contacts).toBeUndefined(); + expect(detail.contact).toBe("Legacy Person"); + expect(detail.phone).toBe("555-0199"); + }); +}); + +describe("mapLocationToBackend contacts payload", () => { + it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { + const payload = mapLocationToBackend({ + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", + status: "Active", + contacts: [ + { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, + { name: "Bob", phone: "(421) 433-0033" }, + ], + }); + + expect(payload.contacts).toEqual([ + { id: 41, name: "Jane", phone: "(421) 433-0032" }, + { name: "Bob", phone: "(421) 433-0033" }, + ]); + expect(payload.contact).toBe("Jane"); + expect(payload.phone).toBe("(421) 433-0032"); + }); + + it("keeps legacy contact/phone untouched when no contacts are provided", () => { + const payload = mapLocationToBackend({ + name: "Legacy DC", + contact: "Legacy Person", + phone: "555-0199", + }); + + expect(payload.contact).toBe("Legacy Person"); + expect(payload.phone).toBe("555-0199"); + expect(payload.contacts).toEqual([]); + }); +}); diff --git a/src/test/domain/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts new file mode 100644 index 00000000..ecf04b56 --- /dev/null +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; + +const validBase: LocationFormValues = { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + contactEmail: "jane@example.com", +}; + +describe("locationSchema contacts", () => { + it("accepts one fully populated contact", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + + expect(result.success).toBe(true); + }); + + it("requires at least one contact", () => { + const result = locationSchema.safeParse({ ...validBase, contacts: [] }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("At least one contact is required"); + } + }); + + it("requires a name and a phone on every row", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [ + { name: "Jane", phone: "" }, + { name: " ", phone: "(421) 433-0032" }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((issue) => issue.message); + expect(messages).toContain("Phone is required"); + expect(messages).toContain("Contact person is required"); + } + }); + + it("trims name and phone values on parse", () => { + const result = locationSchema.safeParse({ + ...validBase, + contacts: [{ name: " Jane ", phone: " (421) 433-0032 " }], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.contacts[0]).toEqual({ name: "Jane", phone: "(421) 433-0032" }); + } + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index b5a65e52..8b33239e 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -28,11 +28,24 @@ describe("vendorsApi.getList", () => { CompanyId: "co-1", CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", Address: "1 Market St", City: "St. Louis", State: "MO", IsActive: false, PreferredContact: "Email", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Email: "adam@gateway.test", + Phone: "314-555-0198", + TradeSpecialties: "Plumbing", + IsActive: false, + TotalJobs: 3, + }, + ], }, ], TotalCount: 1, @@ -52,9 +65,39 @@ describe("vendorsApi.getList", () => { isActive: false, companyId: "co-1", preferredContact: "Email", + technicians: [ + expect.objectContaining({ + id: 7, + email: "adam@gateway.test", + phone: "314-555-0198", + totalJobs: 3, + }), + ], }); }); + it("synthesizes one technician for a legacy ungrouped response", async () => { + apiGet.mockResolvedValueOnce({ + Data: [ + { + Id: 9, + CompanyName: "Legacy Co", + ContactName: "Legacy Tech", + Email: "legacy@example.test", + Phone: "555-0109", + IsActive: true, + }, + ], + TotalCount: 1, + }); + + const result = await vendorsApi.getList(); + + expect(result.items[0]?.technicians).toEqual([ + expect.objectContaining({ id: 9, contactName: "Legacy Tech", phone: "555-0109" }), + ]); + }); + it("forwards all selected facets as repeated indexed query parameters", async () => { await vendorsApi.getList({ companies: ["Gateway Plumbing", "Metro HVAC"], @@ -283,3 +326,96 @@ describe("vendorsApi.getList", () => { await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict"); }); }); + +describe("vendorsApi.getDirectoryList", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ + Data: [ + { + Id: 7, + CompanyId: "co-1", + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", + Address: "1 Market St", + City: "St. Louis", + State: "MO", + IsActive: false, + PreferredContact: "Email", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Email: "adam@gateway.test", + Phone: "314-555-0198", + TradeSpecialties: "Plumbing", + IsActive: false, + TotalJobs: 3, + }, + ], + }, + ], + TotalCount: 1, + }); + }); + + it("targets the dedicated directory endpoint while getList keeps the legacy route", async () => { + await vendorsApi.getDirectoryList({ search: "gateway", isActive: false, page: 1 }); + await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 }); + + expect(API_PATHS.vendor.getDirectoryList).toBe("Vendor/GetVendorDirectoryList"); + expect(API_PATHS.vendor.getList).toBe("Vendor/GetVendorList"); + expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.getDirectoryList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.getList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + }); + + it("builds directory query parameters with the same repeated facet encoding", async () => { + await vendorsApi.getDirectoryList({ + companies: ["Gateway Plumbing", "Metro HVAC"], + trades: ["Plumbing"], + locations: ["St. Louis, MO"], + jobBuckets: ["50-99"], + }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "companies[0]": "Gateway Plumbing", + "companies[1]": "Metro HVAC", + "trades[0]": "Plumbing", + "locations[0]": "St. Louis, MO", + "jobBuckets[0]": "50-99", + }, + }); + }); + + it("maps grouped company rows with the shared grouped-row mapper", async () => { + const result = await vendorsApi.getDirectoryList({ isActive: false, page: 1 }); + + expect(result.items[0]).toMatchObject({ + companyId: "co-1", + companyName: "Gateway Plumbing", + address: "1 Market St", + city: "St. Louis", + state: "MO", + isActive: false, + technicians: [ + expect.objectContaining({ + id: 7, + contactName: "Adam Whyte", + email: "adam@gateway.test", + phone: "314-555-0198", + totalJobs: 3, + }), + ], + }); + }); +}); diff --git a/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx b/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx new file mode 100644 index 00000000..cc898d8f --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-vendor-directory-list.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const getDirectoryList = vi.fn(); +const getList = vi.fn(); + +vi.mock("@/domain/vendors/api/vendors-api", () => ({ + vendorsApi: { + getDirectoryList: (...args: unknown[]) => getDirectoryList(...args), + getList: (...args: unknown[]) => getList(...args), + }, +})); + +import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; +import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; +import { queryKeys } from "@/infra/query-key/query-key"; + +const emptyResult = { items: [], totalCount: 0, page: 1, pageSize: 12 }; + +function createClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useVendorDirectoryList", () => { + beforeEach(() => { + getDirectoryList.mockReset(); + getList.mockReset(); + getDirectoryList.mockResolvedValue(emptyResult); + getList.mockResolvedValue(emptyResult); + }); + + it("fetches through the directory endpoint, never the legacy list", async () => { + const { result } = renderHook(() => useVendorDirectoryList({ isActive: true, page: 1 }), { + wrapper: makeWrapper(createClient()), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getDirectoryList).toHaveBeenCalledWith({ isActive: true, page: 1 }); + expect(getList).not.toHaveBeenCalled(); + }); + + it("keeps a query key distinct from the legacy list key", () => { + const params = { isActive: true, page: 1, pageSize: 12 }; + + expect(queryKeys.vendors.directoryList(params)).not.toEqual(queryKeys.vendors.list(params)); + expect(queryKeys.vendors.directoryList(params)).toEqual( + queryKeys.vendors.directoryList(params), + ); + expect(queryKeys.vendors.directoryList(params)[1]).toBe("directoryList"); + expect(queryKeys.vendors.list(params)[1]).toBe("list"); + }); + + it("caches directory and legacy results independently for the same params", async () => { + const params = { isActive: true, page: 1 } as const; + const client = createClient(); + const directory = renderHook(() => useVendorDirectoryList({ ...params }), { + wrapper: makeWrapper(client), + }); + const legacy = renderHook(() => useVendorsList({ ...params }), { + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(directory.result.current.isSuccess).toBe(true)); + await waitFor(() => expect(legacy.result.current.isSuccess).toBe(true)); + + expect(getDirectoryList).toHaveBeenCalledTimes(1); + expect(getList).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/domain/work-orders/utils/due-date-floor.test.ts b/src/test/domain/work-orders/utils/due-date-floor.test.ts new file mode 100644 index 00000000..b4c52c1b --- /dev/null +++ b/src/test/domain/work-orders/utils/due-date-floor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + clearDueDateBeforeSchedule, + isDueDateBeforeFloor, + resolveDueDateFloor, +} from "@/domain/work-orders/utils/due-date-floor"; + +describe("due date floor (SH-356)", () => { + it("uses the concrete or range-start Schedule On, else the target week's first day", () => { + expect(resolveDueDateFloor({ scheduledOn: "2026-09-10", targetWeek: "" })).toBe("2026-09-10"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "2026-09-14" })).toBe("2026-09-14"); + expect(resolveDueDateFloor({ scheduledOn: "", targetWeek: "" })).toBe(""); + expect(resolveDueDateFloor({ scheduledOn: null, targetWeek: undefined })).toBe(""); + }); + + it("flags only a Due Date strictly before the floor", () => { + expect(isDueDateBeforeFloor("2026-09-09", "2026-09-10")).toBe(true); + expect(isDueDateBeforeFloor("2026-09-10", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("", "2026-09-10")).toBe(false); + expect(isDueDateBeforeFloor("2026-09-09", "")).toBe(false); + }); + + it("clears (never bumps) a Due Date the new schedule passes", () => { + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-15")).toEqual({ dueDate: "" }); + expect(clearDueDateBeforeSchedule("2026-09-12", "2026-09-12")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-20", "2026-09-15")).toEqual({}); + expect(clearDueDateBeforeSchedule("2026-09-12", "")).toEqual({}); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts b/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts new file mode 100644 index 00000000..c7bb498b --- /dev/null +++ b/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp"; + +describe("formatAuditTimestamp (SH-361)", () => { + it("includes the calendar date and the time for an ISO audit time", () => { + const iso = "2026-09-15T19:42:00"; + const date = new Date(iso); + const expectedDate = date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + + const formatted = formatAuditTimestamp(iso); + + expect(formatted).toBe(`${expectedDate} · 7:42 PM`); + expect(formatted).toMatch(/^\d{2}\/\d{2}\/\d{4} · \d{1,2}:\d{2} (AM|PM)$/); + }); + + it("distinguishes the same time of day on different dates", () => { + expect(formatAuditTimestamp("2026-09-14T09:48:00")).not.toBe( + formatAuditTimestamp("2026-09-15T09:48:00"), + ); + }); + + it("keeps pre-formatted or unparseable strings unchanged", () => { + expect(formatAuditTimestamp("· 7:42 PM")).toBe("· 7:42 PM"); + expect(formatAuditTimestamp("09/15/2026 · 7:42 PM")).toBe("09/15/2026 · 7:42 PM"); + }); + + it("returns an empty string when there is no time", () => { + expect(formatAuditTimestamp("")).toBe(""); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index 8c9ff415..e0a3f0b8 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -89,26 +89,67 @@ describe("formatBoardAuditAction", () => { ).toBe("WO marked as Past Due. Schedule On has passed without completion."); }); - it("formats type correction as Changed Type to (AAP, new value only)", () => { + it("formats type correction with the previous and new type (SH-363)", () => { expect( formatBoardAuditAction({ - action: "FieldPatched", + action: "FieldChanged", fieldName: "WorkOrderType", oldValue: "PM", + newValue: "Reactive", + }), + ).toBe('Changed Type from "PM" to "Reactive"'); + }); + + it("formats a first type assignment without a from clause", () => { + expect( + formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "WorkOrderType", + oldValue: "", newValue: "Emergency", }), ).toBe('Changed Type to "Emergency"'); }); - it("formats severity as Changed Severity to SEV N", () => { + it("formats severity with the previous and new SEV labels", () => { expect( formatBoardAuditAction({ - action: "FieldPatched", + action: "FieldChanged", fieldName: "Severity", oldValue: "3", newValue: "1", }), - ).toBe('Changed Severity to "SEV 1"'); + ).toBe('Changed Severity from "SEV 3" to "SEV 1"'); + }); + + it("formats severity cleared by a type correction instead of a raw FieldChanged (SH-363)", () => { + expect( + formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "Severity", + oldValue: "3", + newValue: "", + }), + ).toBe('Changed Severity from "SEV 3" to "—"'); + }); + + it("never renders a raw audit action enum for a cleared unknown field", () => { + const cleared = formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "VendorNte", + oldValue: "100", + newValue: "", + }); + const empty = formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "VendorNte", + oldValue: "", + newValue: "", + }); + + expect(cleared).toBe('Changed vendor nte from "100" to "—"'); + expect(empty).toBe("Updated vendor nte"); + expect([cleared, empty]).not.toContain("FieldChanged"); }); it("falls back to from → to for unknown fields", () => { diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts index 077eff57..1970db61 100644 --- a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { + hasCompletionMediaGallery, isCompletionSurfaceMedia, isExtrasSurfaceMedia, } from "@/domain/work-orders/utils/partition-slide-over-media"; @@ -76,4 +77,33 @@ describe("partition-slide-over-media", () => { }), ).toBe(true); }); + + it("renders a Completion gallery only for Reactive and Emergency work orders", () => { + expect(hasCompletionMediaGallery("Reactive")).toBe(true); + expect(hasCompletionMediaGallery("Emergency")).toBe(true); + expect(hasCompletionMediaGallery("PM")).toBe(false); + expect(hasCompletionMediaGallery("Overdue")).toBe(false); + expect(hasCompletionMediaGallery("")).toBe(false); + }); + + it("puts Extra photos and videos on Extra Docs when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const video = file({ id: "11", fileType: "video", purpose: "Extra", category: "Video" }); + + for (const item of [photo, video]) { + expect(isCompletionSurfaceMedia(item, undefined, false)).toBe(false); + expect(isExtrasSurfaceMedia(item, undefined, false)).toBe(true); + } + }); + + it("ignores stale completion ownership when Completion has no gallery", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership, false)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership, false)).toBe(true); + }); }); diff --git a/src/test/domain/work-orders/utils/severity-escalation.test.ts b/src/test/domain/work-orders/utils/severity-escalation.test.ts index 812e40f9..22c2cc9a 100644 --- a/src/test/domain/work-orders/utils/severity-escalation.test.ts +++ b/src/test/domain/work-orders/utils/severity-escalation.test.ts @@ -18,6 +18,21 @@ describe("shouldAnnounceSeverityEscalation", () => { expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false); }); + it("announces when severity rises from SEV 3–5 into SEV 2", () => { + expect(shouldAnnounceSeverityEscalation(5, 2)).toBe(true); + expect(shouldAnnounceSeverityEscalation(4, 2)).toBe(true); + }); + + it("does not announce moves between SEV 3–5", () => { + expect(shouldAnnounceSeverityEscalation(5, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(4, 3)).toBe(false); + expect(shouldAnnounceSeverityEscalation(3, 5)).toBe(false); + }); + + it("does not announce SEV 1 to SEV 2 (already critical)", () => { + expect(shouldAnnounceSeverityEscalation(1, 2)).toBe(false); + }); + it("does not announce SEV 2 to SEV 1 (already critical)", () => { expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false); }); diff --git a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts index 5e99b7a1..75ea23c6 100644 --- a/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts +++ b/src/test/domain/work-orders/utils/wo-lifecycle-transitions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability"; import { + AUTOMATIC_LIFECYCLE_STATUSES, BOARD_INLINE_STATUSES, COMPLETED_DOC_BLOCK_MSG, COMPLETED_MEDIA_BLOCK_MSG, CREATION_INLINE_STATUSES, SCHEDULED_NEEDS_DATE_MSG, formatAapCompletedBlockTitle, + getInlineStatusTransitions, getStatusOptionState, hasConcreteScheduleDate, maybeAutoSchedulePatch, @@ -17,11 +19,9 @@ import { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; describe("wo-lifecycle-transitions", () => { - it("lists AAP board statuses without Canceled", () => { + it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => { expect(BOARD_INLINE_STATUSES).toEqual([ - "Incomplete", "Pending", - "Scheduled", "En Route", "On Site", "In Progress", @@ -30,6 +30,21 @@ describe("wo-lifecycle-transitions", () => { "Pending Quote", ]); expect(BOARD_INLINE_STATUSES).not.toContain("Canceled"); + for (const automatic of AUTOMATIC_LIFECYCLE_STATUSES) { + expect(BOARD_INLINE_STATUSES).not.toContain(automatic); + } + }); + + it("never offers the current status as a transition (SH-358)", () => { + expect(getInlineStatusTransitions("On Site")).toEqual([ + "Pending", + "En Route", + "In Progress", + "Completed", + "Rescheduled", + "Pending Quote", + ]); + expect(getInlineStatusTransitions("Incomplete")).toEqual(BOARD_INLINE_STATUSES); }); it("lists AAP creation statuses only", () => {