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 de83e430..f5bf551a 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 621f0c0d..1a5be82a 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 0ccb0cab..53ad9890 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 cbb183eb..888665b5 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 1d08f61d..f453475d 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 f1bc8a52..bc5a0bc2 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 eeda3e82..9c1201a3 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 f319fe67..9ccc65ca 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 0035b2f1..50f03dbf 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 2a83659e..45c3c047 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/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index 52d00495..ffa12f37 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index e64d4c12..b21a8ded 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index f07dbc15..907f118a 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 7af374c4..97a77e79 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index bf2ebed4..d2e41781 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 0c225e74..13e7ffe3 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index b8342ed7..27469fe4 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -29,6 +29,8 @@ const activeVendors = [ TotalJobs: 72, IsActive: true, PreferredContact: "Email", + AreaId: 1 as number | null, + AreaName: "East", }, { Id: 2, @@ -47,9 +49,16 @@ const activeVendors = [ Notes: "", TotalJobs: 18, IsActive: true, + AreaId: null as number | null, + AreaName: "", }, ]; +const SERVICE_AREAS = [ + { id: 2, name: "Central" }, + { id: 1, name: "East" }, +]; + const inactiveVendors = [ { ...activeVendors[0], @@ -135,6 +144,7 @@ async function mockVendorApi( googleMapsUrl: vendor.GoogleMapsUrl, })), trades: ["Plumbing", "Backflow Preventers", "HVAC"], + areas: SERVICE_AREAS, locations: [ { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, { city: "Clayton", state: "MO", label: "Clayton, MO" }, @@ -160,13 +170,20 @@ async function mockVendorApi( const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive); const search = (url.searchParams.get("search") ?? "").toLowerCase(); const company = url.searchParams.get("companies[0]"); + const areas = [...url.searchParams.entries()] + .filter(([key]) => key.startsWith("areas[")) + .map(([, value]) => value); const filtered = source.filter( (vendor) => (!search || `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}` .toLowerCase() .includes(search)) && - (!company || vendor.CompanyName === company), + (!company || vendor.CompanyName === company) && + (areas.length === 0 || + areas.some((area) => + area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area, + )), ); const isCount = url.searchParams.get("pageSize") === "1"; const responseRows = filtered.map((vendor) => { @@ -244,6 +261,8 @@ async function mockVendorApi( zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, + areaId: anchor.AreaId, + areaName: anchor.AreaName, technicians: [ ...vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) @@ -307,6 +326,8 @@ async function mockVendorApi( zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, + areaId: anchor.AreaId, + areaName: anchor.AreaName, technicians: vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) .map((vendor) => ({ @@ -410,6 +431,7 @@ test.describe("Vendor directory prototype parity", () => { "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -461,6 +483,7 @@ test.describe("Vendor directory prototype parity", () => { "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -474,7 +497,7 @@ 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(2050); + expect((await page.locator("table").boundingBox())?.width).toBe(2200); expect( (await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox()) ?.height, @@ -540,8 +563,8 @@ test.describe("Vendor directory prototype parity", () => { { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, { 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 }, + { cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, + { cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone }, ]; for (const expected of fields) { @@ -675,7 +698,8 @@ test.describe("Vendor directory prototype parity", () => { "1 Market St", ); await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis"); - await expect(page.getByLabel("State (required)")).toHaveValue("MO"); + await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)"); + await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East"); await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0); await expect(page.getByLabel("Address (optional)")).toHaveCount(0); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); @@ -748,6 +772,55 @@ test.describe("Vendor directory prototype parity", () => { await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); + test("filters by Service Area and lets an admin assign one", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + const gatewayRow = page.getByRole("button", { + name: "Open vendor details for Gateway Plumbing", + }); + const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" }); + await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East"); + await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned"); + + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await page.getByRole("button", { name: "Service Area" }).click(); + await page.getByPlaceholder("All areas").click(); + await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]); + await page.getByRole("option", { name: "East" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); + + await expect( + page.getByRole("button", { name: "Remove filter Service Area: East" }), + ).toBeVisible(); + await expect(gatewayRow).toBeVisible(); + await expect(metroRow).toHaveCount(0); + expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe( + true, + ); + + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await page.getByRole("button", { name: "Service Area" }).click(); + await page.getByRole("button", { name: "Clear all" }).last().click(); + await page.getByPlaceholder("All areas").click(); + await page.getByRole("option", { name: "Unassigned" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); + await expect(page.getByText("Service Area: Unassigned")).toBeVisible(); + await expect(metroRow).toBeVisible(); + await expect(gatewayRow).toHaveCount(0); + + await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click(); + const detailDrawer = page.locator(".MuiDrawer-paper").last(); + const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" }); + await expect(areaField).toBeEnabled(); + await areaField.click(); + await page.getByRole("option", { name: "Central" }).click(); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + await expect.poll(() => state.updatedBody?.areaId).toBe(2); + expect(state.updatedBody?.state).toBe("MO"); + }); + test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); @@ -791,11 +864,14 @@ test.describe("Vendor directory prototype parity", () => { // leave those as the only things it is exercising. await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St"); await page.getByLabel("City (required)").fill("St. Louis"); - await page.getByLabel("State (required)").fill("MO"); + await page.getByLabel("State (required)").fill("Missouri"); + await page.getByRole("option", { name: "Missouri (MO)" }).click(); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); + expect(state.createdBody?.state).toBe("MO"); + expect(state.createdBody?.areaId).toBeNull(); }); test("closes vendor details with Escape and keeps only Edit in the footer", 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/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index fdc90685..0b56ddf3 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -16,6 +16,7 @@ export interface VendorListState { isActive: boolean; companies: string[]; trades: string[]; + areas: string[]; locations: string[]; jobBuckets: string[]; }; @@ -45,6 +46,7 @@ export function useVendorListState(): VendorListState { isActive, companies: appliedFilters.companies, trades: appliedFilters.trades, + areas: appliedFilters.areas, locations: appliedFilters.locations, jobBuckets: appliedFilters.jobBuckets, }), diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index aaf1b606..0beb87fe 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useContext, useMemo, useState } from "react"; import { toast } from "react-toastify"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { @@ -15,6 +15,8 @@ import { type EditedCompanyFields, } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; +import { AuthContext } from "@/providers/auth-context"; +import { isAdminUser } from "@/lib/auth/user-utils"; const VENDOR_ROSTER_NO_CHANGES_MESSAGE = "No changes to save. Enter technician details or update a company field."; @@ -28,6 +30,7 @@ const EDITABLE_COMPANY_FIELDS = [ "state", "zip", "notes", + "areaId", ] as const; /** @@ -48,10 +51,21 @@ import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; import type { VendorCompanyRoster, + VendorFacetArea, VendorFacetCompany, VendorRosterConflict, } from "@/domain/vendors/types/vendor"; +export interface VendorRosterAreaFieldProps { + areaOptions: VendorFacetArea[]; + currentArea: VendorFacetArea | null; + canAssignArea: boolean; +} + +function toCurrentArea(roster: VendorCompanyRoster | null | undefined): VendorFacetArea | null { + return roster && roster.areaId !== "" ? { id: roster.areaId, name: roster.areaName } : null; +} + function withoutBlankNewTechnicians( values: VendorCompanyRosterFormValues, ): VendorCompanyRosterFormValues { @@ -96,6 +110,7 @@ export interface VendorRosterForm { companies: VendorFacetCompany[]; trades: string[]; tradesLoading: boolean; + areaField: VendorRosterAreaFieldProps; selectedCompanyId: string | number | null; selectCompany: (company: VendorFacetCompany | null) => Promise; clearSelectedCompany: (nextName?: string) => void; @@ -116,6 +131,8 @@ export function useVendorRosterForm({ ); const { data: facets, isLoading: facetsLoading } = useVendorFacets(); const save = useSaveVendorCompanyRoster(); + // Area assignment is Admin-only; the API enforces it and the field mirrors it. + const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles); const [conflict, setConflict] = useState(null); const routeRoster = mode === "update" ? query.data : undefined; @@ -151,6 +168,14 @@ export function useVendorRosterForm({ clearConflict, }); const { resetSelection } = selection; + const areaField = useMemo( + () => ({ + areaOptions: facets?.areas ?? [], + currentArea: toCurrentArea(routeRoster ?? selection.selectedRoster), + canAssignArea, + }), + [canAssignArea, facets, routeRoster, selection.selectedRoster], + ); const isValid = useMemo(() => { if (vendorCompanyRosterSchema.safeParse(watched).success) return true; @@ -238,6 +263,7 @@ export function useVendorRosterForm({ companies, trades, tradesLoading: facetsLoading, + areaField, selectedCompanyId: selection.selectedCompanyId, selectCompany: selection.selectCompany, clearSelectedCompany: selection.clearSelectedCompany, diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index a472546e..f0893e32 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -43,6 +43,7 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo // The deprecated stored Maps URL is intentionally not seeded: the form has // no input for it, and carrying it would re-emit a legacy value on write. notes: roster.notes, + areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ id: technician.id, contactName: technician.contactName, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 55e3506d..cf8dcb8e 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -77,6 +77,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { selectedCompanyId={form.selectedCompanyId} onSelectCompany={form.selectCompany} onClearSelectedCompany={form.clearSelectedCompany} + {...form.areaField} /> diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 072bddb7..2a69b204 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -155,6 +155,7 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) { + ); @@ -336,6 +337,7 @@ function DrawerEditor({ tradeOptions={form.trades} tradeOptionsLoading={form.tradesLoading} showTechnicianStatus={false} + {...form.areaField} /> {selectedIndex >= 0 && ( <> diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx index c9be7d45..aac0b212 100644 --- a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -3,6 +3,7 @@ import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; import { Accordion, @@ -17,13 +18,14 @@ import { TextField, Typography, } from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, +import { + type VendorFacetArea, + type VendorFacets, + type VendorFacetCompany, + type VendorFacetJobBucket, + type VendorFacetLocation, } from "@/domain/vendors/types/vendor"; -import type { VendorFilters } from "./vendor-filters"; +import { serviceAreaFilterOptions, type VendorFilters } from "./vendor-filters"; type DraftSetter = Dispatch>; @@ -156,6 +158,33 @@ interface FilterProps { setDraft: DraftSetter; } +function ServiceAreaFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={options.filter((option) => draft.areas.includes(option.id))} + onChange={(_event, value) => + setDraft((current) => ({ ...current, areas: value.map((entry) => entry.id) })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + function LocationFilter({ options, draft, setDraft }: FilterProps) { return ( }> @@ -245,6 +274,11 @@ export function VendorFilterOptions({ <> + diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index b18c568c..1aae6720 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -31,7 +31,11 @@ export function VendorFiltersDrawer({ }, [open, appliedFilters]); const activeCount = - draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + draft.companies.length + + draft.trades.length + + draft.areas.length + + draft.locations.length + + draft.jobBuckets.length; const handleClear = () => { setDraft(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/vendor-filters.ts b/src/app/(protected)/vendors/_components/vendor-filters.ts index 3c236daa..2bd23452 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters.ts +++ b/src/app/(protected)/vendors/_components/vendor-filters.ts @@ -1,6 +1,10 @@ +import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor"; + export interface VendorFilters { companies: string[]; trades: string[]; + /** Stable Area ids or VENDOR_AREA_UNASSIGNED; OR within the facet. */ + areas: string[]; locations: string[]; jobBuckets: string[]; } @@ -8,6 +12,17 @@ export interface VendorFilters { export const emptyVendorFilters: VendorFilters = { companies: [], trades: [], + areas: [], locations: [], jobBuckets: [], }; + +const UNASSIGNED_AREA_OPTION: VendorFacetArea = { id: VENDOR_AREA_UNASSIGNED, name: "Unassigned" }; + +/** Unassigned first, then the active Area catalogue A–Z (case-insensitive). */ +export function serviceAreaFilterOptions(areas: VendorFacetArea[]): VendorFacetArea[] { + const sorted = [...areas].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + return [UNASSIGNED_AREA_OPTION, ...sorted]; +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index cf23d357..d5094cf0 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -30,8 +30,11 @@ import { type RosterTechnicianValues, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { VendorFacetArea, VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import { normalizeStateCode } from "@/data/areas"; import { VendorCompanyContactFields } from "./vendor-company-contact-fields"; +import { VendorServiceAreaField } from "./vendor-service-area-field"; +import { VendorStateSelectField } from "./vendor-state-select-field"; interface VendorRosterFormFieldsProps { control: Control; @@ -43,6 +46,9 @@ interface VendorRosterFormFieldsProps { onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; showTechnicianStatus?: boolean; + areaOptions?: VendorFacetArea[]; + currentArea?: VendorFacetArea | null; + canAssignArea?: boolean; } function CompanyNameField({ @@ -136,6 +142,9 @@ function CompanyFields({ selectedCompanyId, onSelectCompany, onClearSelectedCompany, + areaOptions = [], + currentArea = null, + canAssignArea = false, }: VendorRosterFormFieldsProps) { const notes = useWatch({ control, name: "notes" }) ?? ""; const { field: streetField } = useController({ control, name: "address" }); @@ -163,7 +172,7 @@ function CompanyFields({ // revalidates — a plain assignment would be discarded on save. streetField.onChange(parts.street); cityField.onChange(parts.city); - stateField.onChange(parts.state); + stateField.onChange(normalizeStateCode(parts.state) ?? parts.state); }} label="Street Address (required)" required @@ -183,15 +192,13 @@ function CompanyFields({ fullWidth slotProps={{ htmlInput: { required: true } }} /> - )} /> + ); diff --git a/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx b/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx new file mode 100644 index 00000000..d8171aa0 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-service-area-field.tsx @@ -0,0 +1,58 @@ +import { Controller, type Control } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { VendorFacetArea } from "@/domain/vendors/types/vendor"; + +interface VendorServiceAreaFieldProps { + control: Control; + areaOptions: VendorFacetArea[]; + currentArea?: VendorFacetArea | null; + canAssign: boolean; +} + +export const SERVICE_AREA_ADMIN_ONLY_MESSAGE = "Only administrators can assign a Service Area."; + +/** + * Zero or one Service Area per vendor company, chosen from the Area + * catalogue by stable id. Clearing the selection leaves the company Unassigned. + * The saved Area stays visible when it is no longer in the active catalogue. + */ +export function VendorServiceAreaField({ + control, + areaOptions, + currentArea, + canAssign, +}: VendorServiceAreaFieldProps) { + const options = + currentArea && !areaOptions.some((area) => area.id === currentArea.id) + ? [...areaOptions, currentArea] + : areaOptions; + + return ( + ( + + options={options} + value={options.find((area) => area.id === field.value) ?? null} + onChange={(_event, option) => field.onChange(option?.id ?? "")} + onBlur={field.onBlur} + getOptionLabel={(option) => option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + disabled={!canAssign} + fullWidth + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx b/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx new file mode 100644 index 00000000..db9e0329 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-state-select-field.tsx @@ -0,0 +1,57 @@ +import type { Ref } from "react"; +import { Autocomplete, TextField } from "@mui/material"; +import { + US_STATE_CODES, + formatStateOptionLabel, + normalizeStateCode, + type StateCode, +} from "@/data/areas"; + +interface VendorStateSelectFieldProps { + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + inputRef?: Ref; + error?: boolean; + helperText?: string; +} + +/** + * Closed, searchable list of the 50 US states for the vendor company. + * Legacy full names are shown as their canonical code. Service Area is chosen + * independently, so no Area is derived from the selected State here. + */ +export function VendorStateSelectField({ + value, + onChange, + onBlur, + inputRef, + error, + helperText, +}: VendorStateSelectFieldProps) { + const selected: StateCode | null = normalizeStateCode(value); + return ( + + options={US_STATE_CODES} + value={selected} + onChange={(_event, option) => onChange(option ?? "")} + onBlur={onBlur} + getOptionLabel={formatStateOptionLabel} + fullWidth + renderInput={(params) => ( + + )} + /> + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-table-row.tsx b/src/app/(protected)/vendors/_components/vendor-table-row.tsx index e4ea83db..41e2b740 100644 --- a/src/app/(protected)/vendors/_components/vendor-table-row.tsx +++ b/src/app/(protected)/vendors/_components/vendor-table-row.tsx @@ -155,6 +155,7 @@ function CompanyCells({ row }: { row: VendorListItem }) { + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index acbf10c6..92b13609 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -21,6 +21,7 @@ const TABLE_COLUMNS = [ "Street Address", "City", "State", + "Service Area", "Trade", "Technician", "Technician Phone", @@ -66,7 +67,7 @@ export function VendorsTable({ size="small" stickyHeader sx={{ - minWidth: 2050, + minWidth: 2200, tableLayout: "fixed", "& .MuiTableCell-head": { backgroundColor: "var(--color-bg-muted)", @@ -94,7 +95,7 @@ export function VendorsTable({ }} > - {[190, 160, 220, 220, 140, 100, 190, 220, 170, 110, 120, 130].map((width, index) => ( + {[190, 160, 220, 220, 140, 100, 150, 190, 220, 170, 110, 120, 130].map((width, index) => ( ))} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a9968f39..9adeb936 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -13,7 +13,7 @@ import { VendorsTable } from "./_components/vendors-table"; import { useVendorDeactivation } from "./_components/use-vendor-deactivation"; import { useVendorListState } from "./_components/use-vendor-list-state"; import type { VendorFilters } from "./_components/vendor-filters"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import { VENDOR_AREA_UNASSIGNED, type VendorListItem } from "@/domain/vendors/types/vendor"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useVendorDirectoryList } from "@/domain/vendors/use-cases/use-vendor-directory-list"; @@ -91,6 +91,7 @@ function VendorStatusTabs({ function describeChips( filters: VendorFilters, jobBucketLabels: Map, + areaLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; filters.companies.forEach((value) => @@ -99,6 +100,13 @@ function describeChips( filters.trades.forEach((value) => chips.push({ category: "trades", value, label: `Trade: ${value}` }), ); + filters.areas.forEach((value) => + chips.push({ + category: "areas", + value, + label: `Service Area: ${areaLabels.get(value) ?? value}`, + }), + ); filters.locations.forEach((value) => chips.push({ category: "locations", value, label: `Location: ${value}` }), ); @@ -146,7 +154,15 @@ export default function VendorsListPage() { () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), [facets], ); - const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); + const areaLabels = useMemo( + () => + new Map([ + [VENDOR_AREA_UNASSIGNED, "Unassigned"], + ...(facets?.areas ?? []).map((area): [string, string] => [area.id, area.name]), + ]), + [facets], + ); + const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels, areaLabels); return ( diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx index 57c72270..c64145c8 100644 --- a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx +++ b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx @@ -6,6 +6,7 @@ import { getDispatcherAvatarColor, getInitialsFromName, } from "@/domain/work-orders/utils/dispatcher-avatar-color"; +import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp"; import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action"; function isSystemEntry(entry: WorkOrderAuditEntry): boolean { @@ -15,36 +16,6 @@ function isSystemEntry(entry: WorkOrderAuditEntry): boolean { return !name || name.toLowerCase() === "system"; } -function formatClock(date: Date): string { - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - }); -} - -/** - * Manual (image): `· 9:48 AM` - * System (image): `05/13 · 8:00 AM` - * Prefer API display strings that already include · / AM|PM. - */ -function formatAuditTime(value: string, isSystem: boolean): string { - if (!value) return ""; - if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value; - - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - - const timePart = formatClock(date); - if (isSystem) { - const datePart = date.toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - }); - return `${datePart} · ${timePart}`; - } - return `· ${timePart}`; -} - type SlideOverAuditListProps = { entries: WorkOrderAuditEntry[]; users?: WorkOrderUser[]; @@ -151,7 +122,7 @@ export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListPr color: "#9CA3AF", }} > - {formatAuditTime(entry.createdAt, isSys)} + {formatAuditTimestamp(entry.createdAt)} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx index 51c262be..2853c205 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-actions.tsx @@ -8,13 +8,29 @@ type SlideOverCompletionActionsProps = { docStatus: CompletionDocStatus; rowVersion?: string | number | null; completionPending: boolean; + /** Completion documents require a selected primary service. */ + hasService: boolean; hasUploadHandler: boolean; pdfInputRef: RefObject; onPdfSelected: (files: FileList | null) => void; onOpenCompletionDoc: () => void; }; -function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) { +function DocStatusButtonContent({ + docStatus, + hasService, +}: { + docStatus: CompletionDocStatus; + hasService: boolean; +}) { + if (!hasService) { + return ( + <> + + Select service + + ); + } if (docStatus === "Yes") { return ( <> @@ -43,6 +59,7 @@ export function SlideOverCompletionActions({ docStatus, rowVersion, completionPending, + hasService, hasUploadHandler, pdfInputRef, onPdfSelected, @@ -83,10 +100,17 @@ export function SlideOverCompletionActions({ + {!hasService && ( + + Select a service before opening the completion document. + + )} ); } 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..cf89d2bf 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,8 @@ 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 { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; +import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media"; import { formatMediaSizeLimit, isWorkOrderFileTooLarge, @@ -16,10 +17,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 +53,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-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts index 96b1caa2..1051ea00 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts @@ -11,9 +11,10 @@ export const WO_TABLE_COLS = [ { key: "vendor", label: "VENDOR", w: 184 }, { key: "appt", label: "APPT TIME", w: 128 }, { key: "status", label: "STATUS", w: 148 }, + // Completed Date gates the Completion Document, so it sits between Status and Comp Doc. + { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "doc", label: "COMP DOC", w: 84 }, { key: "uplift", label: "UPLIFT", w: 108 }, - { key: "completed", label: "COMPLETED DATE", w: 130 }, { key: "actions", label: "", w: 100 }, ] as const; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx index c490f17e..14a446fd 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-head.tsx @@ -1,4 +1,5 @@ import { WO_TABLE_COLS } from "./wo-table-cols"; +import { WoTableHeaderLabel } from "./wo-table-header-label"; export function WoTableHead() { return ( @@ -25,7 +26,7 @@ export function WoTableHead() { borderBottom: "1px solid var(--border)", }} > - {col.label} + {Boolean(srLabel) && {srLabel}} ); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-header-label.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-header-label.tsx new file mode 100644 index 00000000..1902b463 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-header-label.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@mui/material"; +import { CircleQuestionMark } from "lucide-react"; + +import { getWoTableHeaderTooltip } from "@/domain/work-orders/constants/wo-table-header-tooltips"; + +type WoTableHeaderLabelProps = { + colKey: string; + label: string; +}; + +export function WoTableHeaderLabel({ colKey, label }: WoTableHeaderLabelProps) { + const tooltip = getWoTableHeaderTooltip(colKey); + + if (!tooltip) { + return <>{label}; + } + + return ( + + {label} + + + + + ); +} 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 #" /> +
- + ); } 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 96f2a62d..7a655742 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/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts index 7522bb47..cbc1da83 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-board-source.ts @@ -33,7 +33,8 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar () => buildResolvedBoardSearchParams({ advApplied: filters.advApplied, - search: filters.search, + // Advanced Filters are global by design; the week-scoped keyword box has no effect. + search: "", page: filters.page, pageSize: filters.pageSize, sortBy: filters.sortBy, @@ -43,7 +44,6 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar }), [ filters.advApplied, - filters.search, filters.page, filters.pageSize, filters.sortBy, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 266d84e3..182a98e5 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -8,6 +8,7 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; import { patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, @@ -112,7 +113,17 @@ export function useWorkOrdersListPage() { generatedAt: data.generatedAt, }; setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData })); - await patchFieldAsync(row.id, { docStatus: status }); + const saved = await patchFieldAsync(row.id, { docStatus: status }); + if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) { + return; + } + try { + // Generation is the one automatic completion — no confirmation dialog. + await patchFieldAsync(row.id, { status: "Completed" }, saved.row); + } catch { + // The document is saved; the table mutation already surfaced the status error and + // refetched, so the WO stays in its prior status for a manual retry. + } }; return { diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 571251cb..e12e040d 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -43,10 +43,12 @@ export default function WorkOrdersListPage() { actions={ filters.setAdvOpen(true)} onNewWorkOrder={() => page.setWizardOpen(true)} activeFilterCount={filters.activeFilterCount} + advancedFiltersActive={filters.advApplied != null} isFetching={tableData.isFetching && !tableData.isLoading} /> } diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index fda4215e..3d925b76 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -56,6 +56,7 @@ function buildListParams(params: VendorsListParams): Record area.id !== "" && area.name !== "") + : [], locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [], jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [], }; diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 27672d30..9fd986d0 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -92,10 +92,18 @@ export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster { zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), + areaId: readString(item, "areaId", "AreaId"), + areaName: readString(item, "areaName", "AreaName"), technicians: mapRosterTechnicians(item.technicians ?? item.Technicians), }; } +/** Form Area ids are strings; the API takes a positive integer id or null (Unassigned). */ +export function toBackendAreaId(value: unknown): number | null { + const parsed = Number(typeof value === "string" ? value.trim() : value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + export function mapRosterTechnicianToBackend(technician: unknown): Record { const item = asRecord(technician); const payload: Record = { @@ -130,6 +138,11 @@ export function mapVendorRosterToBackend(values: unknown): Record = { ...patch.companyFields }; if (companyFields.companyPhone !== undefined) companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone); + // An edited Area is sent as an id or an explicit null (Unassigned). + if (companyFields.areaId !== undefined) + companyFields.areaId = toBackendAreaId(companyFields.areaId); payload.companyFields = companyFields; } return payload; diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts index fb05edee..1f590cbe 100644 --- a/src/domain/vendors/schemas/vendor-roster-schema.ts +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -54,6 +54,8 @@ const baseCompanyFields = { notes: z .string() .max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`), + // Optional stable Area id; "" means Unassigned. Admin-only to change. + areaId: z.string(), technicians: z.array(rosterTechnicianSchema), }; @@ -107,5 +109,6 @@ export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = { state: "", zip: "", notes: "", + areaId: "", technicians: [], }; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index d7747afc..878e2336 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -30,6 +30,10 @@ export interface VendorListItem { tradeSpecialties: string; isActive: boolean; preferredContact: VendorPreferredContact; + /** Stable Area id as a string; "" when the company is Unassigned. */ + areaId: string; + /** Area display name; "" when the company is Unassigned. */ + areaName: string; /** Present on company-grouped directory responses; optional for legacy clients/fixtures. */ technicians?: VendorDirectoryTechnician[]; } @@ -63,6 +67,8 @@ export interface VendorsListParams { trades?: string[]; locations?: string[]; jobBuckets?: string[]; + /** Stable Area ids or the "__unassigned__" sentinel. */ + areas?: string[]; } export interface VendorsListResult { @@ -95,9 +101,19 @@ export interface VendorFacetJobBucket { label: string; } +/** Active Area catalogue option. `id` is the stable areas[n] filter value. */ +export interface VendorFacetArea { + id: string; + name: string; +} + +/** Wire value that selects companies without an Area. */ +export const VENDOR_AREA_UNASSIGNED = "__unassigned__"; + export interface VendorFacets { companies: VendorFacetCompany[]; trades: string[]; + areas: VendorFacetArea[]; locations: VendorFacetLocation[]; jobBuckets: VendorFacetJobBucket[]; } @@ -147,6 +163,9 @@ export interface VendorCompanyRoster { zip: string; googleMapsUrl: string; notes: string; + /** Stable Area id as a string; "" when the company is Unassigned. */ + areaId: string; + areaName: string; technicians: VendorRosterTechnician[]; } diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts index fdfb055a..89e35054 100644 --- a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts +++ b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts @@ -38,6 +38,7 @@ const COMPANY_FIELDS = [ "state", "zip", "notes", + "areaId", ] as const; const TECHNICIAN_FIELDS = [ diff --git a/src/domain/work-orders/constants/wo-table-header-tooltips.ts b/src/domain/work-orders/constants/wo-table-header-tooltips.ts new file mode 100644 index 00000000..24ff23be --- /dev/null +++ b/src/domain/work-orders/constants/wo-table-header-tooltips.ts @@ -0,0 +1,17 @@ +/** + * Help copy for date-column headers on the work orders board table. + * Keyed by the `WO_TABLE_COLS` column key; only columns with copy here show a + * header help icon. `appt` (Appt Time) is an intentional open point — do not + * add it without a business-rule decision. + */ +export const WO_TABLE_HEADER_TOOLTIPS = { + sched: "The date the work is planned to happen. Setting it moves the work order to Scheduled.", + due: "The deadline for the work — when it must be completed by.", + completed: "The date the vendor actually finished the work. Required to complete the work order.", +} as const; + +export function getWoTableHeaderTooltip(colKey: string): string | undefined { + return colKey in WO_TABLE_HEADER_TOOLTIPS + ? WO_TABLE_HEADER_TOOLTIPS[colKey as keyof typeof WO_TABLE_HEADER_TOOLTIPS] + : undefined; +} diff --git a/src/domain/work-orders/errors/board-create-errors.ts b/src/domain/work-orders/errors/board-create-errors.ts new file mode 100644 index 00000000..446eaf5f --- /dev/null +++ b/src/domain/work-orders/errors/board-create-errors.ts @@ -0,0 +1,45 @@ +import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; +import { mapHttpStatusToMessage } from "@/api/api-error"; +import { DISPATCH_REQUIRED_MESSAGE } from "@/domain/work-orders/errors/board-patch-errors"; + +export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again."; + +const DUPLICATE_WO_NUMBER_MESSAGE = + "This work order number is already assigned to another work order."; + +/** Statuses whose mapped copy is fixed product text, never the response body. */ +const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); + +function readErrorCode(data: unknown): string | undefined { + if (typeof data !== "object" || data === null) { + return undefined; + } + const record = data as Record; + const code = record.code ?? record.Code; + return typeof code === "string" ? code : undefined; +} + +/** + * why: board create returns backend validation text ("siteCode is required.", + * "vendorId is required when setting appointment fields.") that must never reach the + * screen. Unknown failures fall back to generic copy, not to `error.message`. + */ +export function boardCreateErrorMessage(error: unknown): string { + if (isHTTPError(error)) { + const code = readErrorCode(error.data); + if (code === "DispatchRequired") { + return DISPATCH_REQUIRED_MESSAGE; + } + if (code === "DuplicateWoNumber") { + return DUPLICATE_WO_NUMBER_MESSAGE; + } + const status = error.response.status; + return FIXED_COPY_STATUSES.has(status) + ? mapHttpStatusToMessage(status) + : BOARD_CREATE_FAILED_MESSAGE; + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return BOARD_CREATE_FAILED_MESSAGE; +} diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts index d4e9fdbd..00779894 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -22,10 +22,12 @@ export class BoardPatchValidationError extends Error { } } +export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving."; + /** why: backend field names must never be exposed through user-facing board toasts. */ export function boardPatchValidationMessage(error: BoardPatchValidationError): string { if (error.code === "DispatchRequired") { - return "Select a vendor company before saving."; + return DISPATCH_REQUIRED_MESSAGE; } return error.message; } diff --git a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts index 205f010e..4aa4c8b8 100644 --- a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts +++ b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts @@ -4,6 +4,8 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import { boardCreateErrorMessage } from "@/domain/work-orders/errors/board-create-errors"; + import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; @@ -27,7 +29,7 @@ export function useCreateWorkOrderFromWizard( }, onError: (error: Error) => { - toast.error(error.message || "Failed to create work order"); + toast.error(boardCreateErrorMessage(error)); }, }); } diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index 6922c367..a02950d3 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -134,11 +134,17 @@ export function useWorkOrderTableMutations( mutationFn: ({ id, persistPatch, + baseRow, }: { id: string | number; patch: WorkOrderTablePatch; persistPatch: WorkOrderTablePatch; - }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)), + /** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */ + baseRow?: WorkOrderTableRow; + }) => + enqueuePatch(id, () => + runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow), + ), onMutate: async ({ id, persistPatch }) => { onPatch(id, persistPatch); }, @@ -182,9 +188,13 @@ export function useWorkOrderTableMutations( ); }; - const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => { - const persistPatch = resolvePatch(id, patch); - return patchMutation.mutateAsync({ id, patch, persistPatch }); + const patchFieldAsync = ( + id: string | number, + patch: WorkOrderTablePatch, + baseRow?: WorkOrderTableRow, + ) => { + const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); + return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }); }; return { 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/completion-doc-auto-complete.ts b/src/domain/work-orders/utils/completion-doc-auto-complete.ts new file mode 100644 index 00000000..e27f0318 --- /dev/null +++ b/src/domain/work-orders/utils/completion-doc-auto-complete.ts @@ -0,0 +1,25 @@ +import type { CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; + +/** + * Completion is manual with confirmation, except that generating the completion document + * completes the WO — but only when the closability gate passes on the persisted row. + * Not-required (NN) and drafts never complete; Past Due rows must be + * rescheduled before any status change, so they are left for the dispatcher. + */ +export function shouldCompleteAfterCompletionDoc( + status: CompDocSaveStatus, + row: WorkOrderTableRow, +): boolean { + if (status !== "Yes" || row.docStatus !== "Yes") { + return false; + } + if (row.status === "Completed" || row.status === "Canceled" || row.isPastDue) { + return false; + } + return getClosabilityGaps(tableRowToClosabilityInput(row)).length === 0; +} 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/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 3202bd40..031b589e 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -68,6 +68,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [], }; @@ -134,6 +136,7 @@ function submitValues( state: "MO", zip: "63101", notes: "", + areaId: "", technicians, }; } @@ -300,6 +303,7 @@ describe("useVendorRosterForm prototype defaults", () => { state: "", zip: "", notes: "", + areaId: "", technicians: [ { contactName: "", diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index e96fd031..355f99f1 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -12,6 +12,8 @@ import { type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; import { renderWithProviders } from "@/test/test-utils"; +import type { StateCode } from "@/data/areas"; +import { pickVendorState } from "./vendor-state-select"; vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }), @@ -58,16 +60,18 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); } -function fillRequiredAddress({ street = "6735 Trippel Rd", city = "Theodore", state = "AL" } = {}) { +function fillRequiredAddress({ + street = "6735 Trippel Rd", + city = "Theodore", + state = "AL" as StateCode, +} = {}) { fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), { target: { value: street }, }); fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { target: { value: city }, }); - fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { - target: { value: state }, - }); + pickVendorState(state); } describe("VendorCreateModal validation", () => { diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index 8777567d..0da2e959 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -39,6 +39,8 @@ const vendor: VendorListItem = { tradeSpecialties: "", isActive: true, preferredContact: "Phone", + areaId: "", + areaName: "", }; function rosterWith(technicians: Array>) { @@ -57,6 +59,8 @@ function rosterWith(technicians: Array>) { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians, }, isLoading: false, diff --git a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx index 28a3b106..2a3bcc81 100644 --- a/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-roster-address-fields.test.tsx @@ -11,11 +11,15 @@ import { } from "@/domain/vendors/schemas/vendor-roster-schema"; import { suggestAddresses } from "@/lib/address/vendor-address"; import { renderWithProviders } from "@/test/test-utils"; +import { formatStateOptionLabel, normalizeStateCode } from "@/data/areas"; +import { pickVendorState, stateField } from "./vendor-state-select"; const TYPED_STREET = "415 Murphy Rd"; const FIRST_SUGGESTION = suggestAddresses(TYPED_STREET)[0]; if (FIRST_SUGGESTION == null) throw new Error("expected a suggestion for the typed street"); +const FIRST_SUGGESTION_STATE = normalizeStateCode(FIRST_SUGGESTION.state); +if (FIRST_SUGGESTION_STATE == null) throw new Error("expected a canonical suggestion state"); function AddressFormHarness({ onValid, @@ -67,9 +71,7 @@ describe("VendorRosterFormFields address autocomplete", () => { expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( FIRST_SUGGESTION.city, ); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( - FIRST_SUGGESTION.state, - ); + expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE)); }); it("submits the settled structured parts, not the option label", async () => { @@ -104,14 +106,12 @@ describe("VendorRosterFormFields address autocomplete", () => { expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue( FIRST_SUGGESTION.city, ); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue( - FIRST_SUGGESTION.state, - ); + expect(stateField()).toHaveValue(formatStateOptionLabel(FIRST_SUGGESTION_STATE)); await user.click(screen.getByRole("button", { name: "Clear" })); expect(street).toHaveValue(""); expect(screen.getByRole("textbox", { name: "City (required)" })).toHaveValue(""); - expect(screen.getByRole("textbox", { name: "State (required)" })).toHaveValue(""); + expect(stateField()).toHaveValue(""); }); }); @@ -132,9 +132,7 @@ describe("VendorRosterFormFields map preview empty state", () => { fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), { target: { value: "Theodore" }, }); - fireEvent.change(screen.getByRole("textbox", { name: "State (required)" }), { - target: { value: "AL" }, - }); + pickVendorState("AL"); expect(await screen.findByTitle(`Map of ${TYPED_STREET} Theodore AL`)).toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); diff --git a/src/test/app/(protected)/vendors/vendor-service-area.test.tsx b/src/test/app/(protected)/vendors/vendor-service-area.test.tsx new file mode 100644 index 00000000..bb19416a --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-service-area.test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { SERVICE_AREA_ADMIN_ONLY_MESSAGE } from "@/app/(protected)/vendors/_components/vendor-service-area-field"; +import { VendorFilterOptions } from "@/app/(protected)/vendors/_components/vendor-filter-options"; +import { + emptyVendorFilters, + serviceAreaFilterOptions, +} from "@/app/(protected)/vendors/_components/vendor-filters"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { VENDOR_AREA_UNASSIGNED, type VendorFacetArea } from "@/domain/vendors/types/vendor"; +import { + mapVendorRosterAdditivePatchToBackend, + mapVendorRosterToBackend, +} from "@/domain/vendors/mappers/vendor-roster-mapper"; +import { mapVendorFacets, mapVendorListItem } from "@/domain/vendors/mappers/vendor-mapper"; +import { renderWithProviders } from "@/test/test-utils"; +import { pickVendorState, stateField } from "./vendor-state-select"; + +const AREAS: VendorFacetArea[] = [ + { id: "1", name: "East" }, + { id: "2", name: "Central" }, +]; + +function Harness({ + canAssignArea, + currentArea = null, + defaults = {}, + onValid, +}: { + canAssignArea: boolean; + currentArea?: VendorFacetArea | null; + defaults?: Partial; + onValid: (values: VendorCompanyRosterFormValues) => void; +}) { + const { control, handleSubmit, formState } = useForm({ + defaultValues: { ...emptyVendorCompanyRosterForm, ...defaults }, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
+ + + + ); +} + +const COMPLETE_COMPANY: Partial = { + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + address: "1 Market St", + city: "St. Louis", + state: "MO", +}; + +describe("Vendor Service Area field", () => { + it("lets an admin assign one Service Area by stable id", async () => { + const onValid = vi.fn(); + renderWithProviders(, { + withAuth: false, + }); + + const area = screen.getByRole("combobox", { name: "Service Area (optional)" }); + expect(area).toBeEnabled(); + fireEvent.change(area, { target: { value: "Cen" } }); + fireEvent.click(screen.getByRole("option", { name: "Central" })); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("2"); + }); + + it("shows the saved Area read-only to non-admins and keeps it on submit", async () => { + const onValid = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + const area = screen.getByRole("combobox", { name: "Service Area (optional)" }); + expect(area).toBeDisabled(); + expect(area).toHaveValue("Archived Zone"); + expect(screen.getByText(SERVICE_AREA_ADMIN_ONLY_MESSAGE)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => expect(onValid).toHaveBeenCalledTimes(1)); + expect((onValid.mock.calls[0]?.[0] as VendorCompanyRosterFormValues).areaId).toBe("9"); + }); + + it("does not derive the Service Area from the selected State", () => { + renderWithProviders(, { withAuth: false }); + + pickVendorState("CA"); + + expect(stateField()).toHaveValue("California (CA)"); + expect(screen.getByRole("combobox", { name: "Service Area (optional)" })).toHaveValue(""); + expect(screen.queryByText(/Area:/)).not.toBeInTheDocument(); + }); +}); + +describe("Vendor State field", () => { + it("is a closed list: free text is not kept and legacy names show as codes", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(stateField()).toHaveValue("Missouri (MO)"); + fireEvent.change(stateField(), { target: { value: "Ontario" } }); + expect(screen.queryByRole("option", { name: /Ontario/ })).not.toBeInTheDocument(); + fireEvent.blur(stateField()); + expect(stateField()).toHaveValue("Missouri (MO)"); + }); +}); + +describe("Service Area contracts", () => { + it("orders filter options Unassigned first, then names A–Z", () => { + expect( + serviceAreaFilterOptions([ + { id: "3", name: "West" }, + { id: "4", name: "california" }, + { id: "1", name: "East" }, + ]).map((option) => option.id), + ).toEqual([VENDOR_AREA_UNASSIGNED, "4", "1", "3"]); + }); + + it("maps facet areas and directory Area fields from the API", () => { + expect( + mapVendorFacets({ + Areas: [ + { Id: 1, Name: "East" }, + { Id: 0, Name: "" }, + ], + }).areas, + ).toEqual([{ id: "1", name: "East" }]); + const row = mapVendorListItem({ Id: 1, AreaId: 2, AreaName: "Central" }); + expect(row.areaId).toBe("2"); + expect(row.areaName).toBe("Central"); + }); + + it("round-trips form Areas, sends explicit null for Unassigned, and omits absent Areas", () => { + expect(mapVendorRosterToBackend({ name: "A", areaId: "3" }).areaId).toBe(3); + expect(mapVendorRosterToBackend({ name: "A", areaId: "" }).areaId).toBeNull(); + expect(mapVendorRosterToBackend({ name: "A" })).not.toHaveProperty("areaId"); + expect( + mapVendorRosterAdditivePatchToBackend({ + rowVersion: "rv", + addTechnicians: [], + companyFields: { areaId: "" }, + }).companyFields, + ).toEqual({ areaId: null }); + expect( + mapVendorRosterAdditivePatchToBackend({ rowVersion: "rv", addTechnicians: [] }), + ).not.toHaveProperty("companyFields"); + }); +}); + +describe("Vendor Service Area filter section", () => { + it("sits between Trade and Location", () => { + const setDraft = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([ + "Company", + "Trade", + "Service Area", + "Location", + "Total Jobs", + ]); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-state-select.ts b/src/test/app/(protected)/vendors/vendor-state-select.ts new file mode 100644 index 00000000..f8694649 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-state-select.ts @@ -0,0 +1,15 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { formatStateOptionLabel, type StateCode } from "@/data/areas"; + +export const STATE_FIELD_NAME = "State (required)"; + +export function stateField(): HTMLElement { + return screen.getByRole("combobox", { name: STATE_FIELD_NAME }); +} + +/** Picks a state from the closed vendor State list by its option label. */ +export function pickVendorState(code: StateCode): void { + const label = formatStateOptionLabel(code); + fireEvent.change(stateField(), { target: { value: label } }); + fireEvent.click(screen.getByRole("option", { name: label })); +} diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index ab905729..2b98c7f0 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -46,6 +46,8 @@ const activeVendor: VendorListItem = { tradeSpecialties: "Backflow Preventers", isActive: true, preferredContact: "Email", + areaId: "", + areaName: "", }; const inactiveVendor = { @@ -67,6 +69,8 @@ const activeRoster: VendorCompanyRoster = { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [ { id: 1, @@ -668,4 +672,30 @@ describe("VendorsListPage", () => { expect(screen.getByRole("tab", { name: "Active (3)" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Inactive (2)" })).toBeInTheDocument(); }); + + it("shows the Service Area column after State with Unassigned for companies without an Area", () => { + setupDefaults(); + const assigned: VendorListItem = { ...activeVendor, areaId: "1", areaName: "East" }; + const unassigned: VendorListItem = { + ...activeVendor, + id: 9, + companyId: "co-9", + companyName: "No Area Co", + }; + useVendorDirectoryList.mockImplementation( + (params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 2); + return params.isActive ? result([assigned, unassigned], 2) : result([], 0); + }, + ); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + const headers = screen.getAllByRole("columnheader").map((header) => header.textContent); + expect(headers.indexOf("Service Area")).toBe(headers.indexOf("State") + 1); + expect(headers.indexOf("Trade")).toBe(headers.indexOf("Service Area") + 1); + expect( + screen.getAllByTestId("vendor-text-service-area").map((cell) => cell.textContent), + ).toEqual(["East", "Unassigned"]); + }); }); 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( + + + + +
, + { 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-completion-service-gate.test.tsx b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx new file mode 100644 index 00000000..d87162fe --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) { + const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow; + render( + , + ); + return onOpenCompletionDoc; +} + +describe("SlideOverCompletionTab service gate", () => { + it("disables the completion document button until a service is selected", () => { + const onOpen = renderTab(" "); + + const button = screen.getByRole("button", { name: /select service/i }); + expect(button).toBeDisabled(); + expect( + screen.getByText("Select a service before opening the completion document."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /open completion document/i }), + ).not.toBeInTheDocument(); + + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("opens the completion document when a service is selected", () => { + const onOpen = renderTab("HVAC Inspection"); + + const button = screen.getByRole("button", { name: /open completion document/i }); + expect(button).toBeEnabled(); + expect(screen.queryByText(/select service/i)).not.toBeInTheDocument(); + + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); 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/use-work-orders-list-page-completion-doc.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts new file mode 100644 index 00000000..60c004c7 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const patchField = vi.hoisted(() => vi.fn()); +const patchFieldAsync = vi.hoisted(() => vi.fn()); + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen: false, + setAdvOpen: vi.fn(), + advApplied: null, + advInitial: EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: vi.fn(), + clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, + weekMonday: "2026-07-20", + selectedDayIso: "2026-07-20", + setWeekMonday: vi.fn(), + setSelectedDayIso: vi.fn(), + navigateToDay: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + types: new Set(), + setTypes: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ + patchField, + patchFieldAsync, + isPending: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [] }), +})); + +import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; + +function closableRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 77, + woNumber: "24866", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + company: "Vendor Co", + vendorId: "7", + tech: "Tech One", + completedDate: "2026-09-15", + pm: "HVAC", + status: "In Progress", + docStatus: "No", + mediaCount: 0, + isPastDue: false, + rowVersion: "v1", + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + } as WorkOrderTableRow; +} + +describe("useWorkOrdersListPage completion document (SH-341)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("completes the WO without confirmation once Generate persists the document", async () => { + const saved = closableRow({ docStatus: "Yes", rowVersion: "v2" }); + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: saved }); + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: { ...saved, status: "Completed" }, + }); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + + expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" }); + expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved); + expect(patchField).not.toHaveBeenCalled(); + expect(result.current.confirmComplete).toBeNull(); + }); + + it("does not complete on Save draft or Not required", async () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "No" }) }); + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "No"); + patchFieldAsync.mockResolvedValueOnce({ kind: "board", row: closableRow({ docStatus: "NN" }) }); + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "" }, "NN"); + + expect(patchFieldAsync).toHaveBeenCalledTimes(2); + expect(patchFieldAsync).not.toHaveBeenCalledWith( + 77, + { status: "Completed" }, + expect.anything(), + ); + }); + + it("keeps the closability gate: a missing service leaves the status unchanged", async () => { + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: closableRow({ docStatus: "Yes", pm: "" }), + }); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"); + + expect(patchFieldAsync).toHaveBeenCalledTimes(1); + }); + + it("keeps the generated document when the automatic completion is rejected", async () => { + patchFieldAsync.mockResolvedValueOnce({ + kind: "board", + row: closableRow({ docStatus: "Yes" }), + }); + patchFieldAsync.mockRejectedValueOnce(new Error("Work order was modified.")); + const { result } = renderHook(() => useWorkOrdersListPage()); + + await expect( + result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes"), + ).resolves.toBeUndefined(); + expect(patchFieldAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx b/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx new file mode 100644 index 00000000..aeee1da9 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-column-order.test.tsx @@ -0,0 +1,116 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols"; +import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => cell:service, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: () => cell:vendor, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => cell:appt, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => cell:status, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => cell:completed, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({ + CompDocCell: () => cell:doc, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({ + UpliftCell: () => cell:uplift, +})); + +/** Documented left-to-right data column order for the Work Orders board. */ +const DOCUMENTED_ORDER = [ + "SITE", + "WO", + "TYPE OF WO", + "ASSIGNED TO", + "SCHEDULE ON", + "DUE DATE", + "SERVICE", + "VENDOR", + "APPT TIME", + "STATUS", + "COMPLETED DATE", + "COMP DOC", + "UPLIFT", +]; + +describe("Work Orders board column order", () => { + it("renders the documented header order with grip/flag before Site and actions after Uplift", () => { + renderWithProviders( + + +
, + { withAuth: false }, + ); + + const headers = screen.getAllByRole("columnheader").map((th) => th.textContent?.trim() ?? ""); + expect(headers.filter(Boolean)).toEqual(expect.arrayContaining(DOCUMENTED_ORDER)); + expect(WO_TABLE_COLS.map((col) => col.key)).toEqual([ + "grip", + "flag", + "site", + "wo", + "type", + "assigned", + "sched", + "due", + "pm", + "vendor", + "appt", + "status", + "completed", + "doc", + "uplift", + "actions", + ]); + expect(WO_TABLE_COLS.map((col) => col.label).filter(Boolean)).toEqual(DOCUMENTED_ORDER); + }); + + it("renders row cells under the matching headers: Status, Completed Date, Comp Doc, Uplift", () => { + const row = { + id: 7, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + } as WorkOrderTableRow; + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + const cells = within(screen.getByTestId("row")) + .getAllByRole("cell") + .map((td) => td.textContent); + expect(cells).toEqual([ + "cell:service", + "cell:vendor", + "cell:appt", + "cell:status", + "cell:completed", + "cell:doc", + "cell:uplift", + ]); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-head-date-tooltips.test.tsx b/src/test/app/(protected)/workorders/wo-table-head-date-tooltips.test.tsx new file mode 100644 index 00000000..a25bd953 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-head-date-tooltips.test.tsx @@ -0,0 +1,71 @@ +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head"; +import { getWoTableHeaderTooltip } from "@/domain/work-orders/constants/wo-table-header-tooltips"; +import { renderWithProviders } from "@/test/test-utils"; + +const CASES = [ + { + columnName: "SCHEDULE ON", + tooltip: + "The date the work is planned to happen. Setting it moves the work order to Scheduled.", + }, + { + columnName: "DUE DATE", + tooltip: "The deadline for the work — when it must be completed by.", + }, + { + columnName: "COMPLETED DATE", + tooltip: "The date the vendor actually finished the work. Required to complete the work order.", + }, +]; + +function renderHead() { + return renderWithProviders( + + +
, + { withAuth: false }, + ); +} + +describe("WoTableHead date column header tooltips (SH-195)", () => { + it.each(CASES)( + "shows the $columnName help icon and tooltip copy on hover", + async ({ columnName, tooltip }) => { + const user = userEvent.setup(); + renderHead(); + + const header = screen.getByRole("columnheader", { name: new RegExp(`^${columnName}`) }); + const icon = within(header).getByRole("button", { name: `${columnName} column help` }); + + await user.hover(icon); + + const tip = await screen.findByText(tooltip); + expect(tip).toBeInTheDocument(); + }, + 15000, + ); + + it("moves focus to the help icon on tab and keeps it keyboard reachable", async () => { + const user = userEvent.setup(); + renderHead(); + + const header = screen.getByRole("columnheader", { name: /^SCHEDULE ON/ }); + const icon = within(header).getByRole("button", { name: "SCHEDULE ON column help" }); + + await user.tab(); + + expect(icon).toHaveFocus(); + }); + + it("does not add a help icon to Appt Time (open point, not implemented)", () => { + renderHead(); + + const header = screen.getByRole("columnheader", { name: "APPT TIME" }); + expect(within(header).queryByRole("img")).not.toBeInTheDocument(); + expect(getWoTableHeaderTooltip("appt")).toBeUndefined(); + }); +}); 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/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index c3fb07b9..7e4df369 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -189,7 +189,12 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ describe("WorkOrdersListHeader slice affordances", () => { it("hides New WO and Advanced Filters when callbacks are omitted", () => { renderWithProviders( - , + , { withAuth: false }, ); @@ -202,6 +207,7 @@ describe("WorkOrdersListHeader slice affordances", () => { renderWithProviders( ({ + searchBoard: vi.fn(), + getBoard: vi.fn(), + emptyList: [] as const, +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: T) => value, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + getBoard: (...args: unknown[]) => getBoard(...args), + }, +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: emptyList }), + useWorkOrderVendors: () => ({ data: emptyList }), +})); + +import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; +import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; + +const EMPTY_RESULT = { items: [], totalCount: 0, page: 0, pageSize: 25 }; + +function useBoardWithFilters() { + const filters = useWorkOrdersListFilters(); + useWorkOrdersBoardSource(filters); + return filters; +} + +function renderBoardSource() { + const client = createTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useBoardWithFilters(), { wrapper }); +} + +describe("keyword search scope indicator", () => { + it("shows no scope badge while the search box is empty", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); + + it("names the active week while a keyword is typed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Searching within 5/4–5/8")).toBeInTheDocument(); + }); + + it("hides the scope label while Advanced Filters are active", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByText(/searching within/i)).not.toBeInTheDocument(); + }); +}); + +describe("keyword search vs Advanced Filters scope", () => { + beforeEach(() => { + searchBoard.mockReset().mockResolvedValue(EMPTY_RESULT); + getBoard.mockReset().mockResolvedValue(EMPTY_RESULT); + }); + + it("scopes keyword search to the active week", async () => { + const { result } = renderBoardSource(); + + act(() => { + result.current.setWeekMonday("2026-05-04"); + result.current.handleSearchChange("hvac"); + }); + + await waitFor(() => + expect(getBoard).toHaveBeenCalledWith( + expect.objectContaining({ weekStart: "2026-05-04", weekEnd: "2026-05-08", search: "hvac" }), + ), + ); + expect(searchBoard).not.toHaveBeenCalled(); + }); + + it("keeps Advanced Filters global and ignores the week-scoped keyword", async () => { + const { result } = renderBoardSource(); + + act(() => { + result.current.handleSearchChange("hvac"); + result.current.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, rescheduled: true }); + }); + + await waitFor(() => expect(searchBoard).toHaveBeenCalled()); + const params = searchBoard.mock.lastCall?.[0] as Record; + expect(params.search).toBeUndefined(); + expect(params).not.toHaveProperty("weekStart"); + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index 8b33239e..38a6bd20 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -419,3 +419,24 @@ describe("vendorsApi.getDirectoryList", () => { }); }); }); + +describe("vendorsApi.getDirectoryList Service Area filter", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ Data: [], TotalCount: 0 }); + }); + + it("sends areas[n] stable ids and the Unassigned sentinel", async () => { + await vendorsApi.getDirectoryList({ page: 1, areas: ["1", "__unassigned__"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getDirectoryList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "areas[0]": "1", + "areas[1]": "__unassigned__", + }, + }); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 4c9a463f..3f4beef9 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -40,6 +40,8 @@ describe("vendor roster mapper", () => { zip: "", googleMapsUrl: "", notes: "", + areaId: "", + areaName: "", technicians: [ { id: 7, diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx index aaec2167..b6dcae4d 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -40,6 +40,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "", notes: "Preferred vendor", + areaId: "", + areaName: "", technicians: [ { id: 11, @@ -75,6 +77,7 @@ function formValues( state: roster.state, zip: roster.zip, notes: roster.notes, + areaId: roster.areaId, technicians: [newTechnician], ...overrides, }; diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts index 735ef2d7..3aabb058 100644 --- a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -14,6 +14,8 @@ const roster: VendorCompanyRoster = { zip: "63101", googleMapsUrl: "http://maps.example.test/legacy", notes: "Legacy values remain untouched", + areaId: "", + areaName: "", technicians: [ { id: 7, @@ -37,6 +39,7 @@ const values = { state: roster.state, zip: roster.zip, notes: roster.notes, + areaId: roster.areaId, technicians: roster.technicians.map((technician) => ({ id: technician.id, contactName: technician.contactName, diff --git a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts index 9b74c71a..1820df72 100644 --- a/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts +++ b/src/test/domain/work-orders/mappers/completed-date-board-column.test.ts @@ -4,10 +4,11 @@ import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; describe("COMPLETED DATE board column wiring", () => { - it("includes COMPLETED DATE column after COMP DOC", () => { + it("includes COMPLETED DATE column between STATUS and COMP DOC", () => { const keys = WO_TABLE_COLS.map((col) => col.key); expect(keys).toContain("completed"); - expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc")); + expect(keys.indexOf("completed")).toBe(keys.indexOf("status") + 1); + expect(keys.indexOf("completed")).toBeLessThan(keys.indexOf("doc")); }); it("expands completedDate patch to board field op", () => { diff --git a/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx new file mode 100644 index 00000000..59392029 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx @@ -0,0 +1,90 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HTTPError } from "ky"; +import type { ReactNode } from "react"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeApiRequestError } from "@/api/api-error"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +import { createTestQueryClient } from "@/test/test-utils"; + +const createFromBoard = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { createFromBoard: (...args: unknown[]) => createFromBoard(...args) }, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +/** Mirrors the ky client: HTTPError with parsed body, message normalized from it. */ +function backendError(status: number, body: Record): HTTPError { + const error = new HTTPError( + new Response(JSON.stringify(body), { status }), + new Request("https://api.example.test/workorders/board"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +async function createAndGetToast(error: unknown): Promise { + createFromBoard.mockRejectedValueOnce(error); + const { result } = renderHook(() => useCreateWorkOrderFromWizard(), { wrapper }); + act(() => { + result.current.mutate(EMPTY_WIZARD_DRAFT); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("useCreateWorkOrderFromWizard error copy", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + createFromBoard.mockReset(); + }); + + it("never surfaces raw backend validation text from a 400", async () => { + const message = await createAndGetToast( + backendError(400, { status: "Validation Error", message: "siteCode is required." }), + ); + + expect(message).toBe("Unable to create the work order. Please try again."); + }); + + it("maps a coded 422 to product copy instead of the backend field name", async () => { + const message = await createAndGetToast( + backendError(422, { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }), + ); + + expect(message).toBe("Select a vendor company before saving."); + }); + + it("falls back to generic copy for unknown 422 codes", async () => { + const message = await createAndGetToast( + backendError(422, { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus must be Incomplete, Pending, or Scheduled at create.", + }), + ); + + expect(message).toBe("Unable to create the work order. Please try again."); + }); + + it("keeps fixed permission copy for a 403", async () => { + const message = await createAndGetToast( + backendError(403, { code: "Forbidden", message: "accountId not permitted" }), + ); + + expect(message).toBe("You do not have permission to perform this action."); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index d3e1095d..97f364b5 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -224,6 +224,35 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate); expect(serverState.status).toBe(BASE_SERVER.status); }); + it("sends the follow-up status patch with the rowVersion of the just-saved row (SH-341)", async () => { + const staleTableRow = { ...BASE_ROW }; + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: vi.fn(), + clearPatch: () => undefined, + getRow: () => staleTableRow, + }), + { wrapper: makeWrapper() }, + ); + + let saved: Awaited> | undefined; + await act(async () => { + saved = await result.current.patchFieldAsync(42, { docStatus: "Yes" }); + }); + expect(saved?.kind).toBe("board"); + const savedRow = saved?.kind === "board" ? saved.row : undefined; + + await act(async () => { + await result.current.patchFieldAsync(42, { status: "Completed" }, savedRow); + }); + + expect(patchBoardField).toHaveBeenLastCalledWith(42, { + field: "lifecycleStatus", + value: "Completed", + workOrderVersion: "AAAAAAA=-next", + }); + }); }); describe("maybeAutoSchedule on board patch", () => { diff --git a/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts new file mode 100644 index 00000000..ab12e39e --- /dev/null +++ b/src/test/domain/work-orders/utils/completion-doc-auto-complete.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; + +function closablePmRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 42, + woNumber: "24866", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + company: "Vendor Co", + vendorId: "7", + tech: "Tech One", + completedDate: "2026-09-15", + pm: "HVAC", + status: "In Progress", + docStatus: "Yes", + mediaCount: 0, + isPastDue: false, + upliftSummary: { hasUplift: false, pendingCount: 0 }, + ...overrides, + } as WorkOrderTableRow; +} + +describe("shouldCompleteAfterCompletionDoc (SH-341)", () => { + it("completes a closable PM once the generated document is persisted", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow())).toBe(true); + }); + + it("never completes on Save draft or Not required", () => { + expect(shouldCompleteAfterCompletionDoc("No", closablePmRow({ docStatus: "No" }))).toBe(false); + expect(shouldCompleteAfterCompletionDoc("NN", closablePmRow({ docStatus: "NN" }))).toBe(false); + }); + + it("does not complete when the server did not persist the document as generated", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ docStatus: "No" }))).toBe(false); + }); + + it.each([ + ["dispatcher", { dispatcherId: "", dispatcherName: "" }], + ["vendor", { company: "", vendorId: "" }], + ["completed date", { completedDate: "" }], + ["real WO number", { woNumber: "SH02137" }], + ["service", { pm: "" }], + ["pending uplift", { upliftSummary: { hasUplift: true, pendingCount: 1 } }], + ["photo on a Reactive WO", { type: "Reactive" as const, mediaCount: 0 }], + ])("keeps the SH-114 closability gate: missing %s blocks completion", (_label, overrides) => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow(overrides))).toBe(false); + }); + + it("completes a Reactive WO that has its completion photo", () => { + expect( + shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ type: "Reactive", mediaCount: 1 })), + ).toBe(true); + }); + + it("leaves Completed, Canceled, and Past Due rows unchanged", () => { + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Completed" }))).toBe( + false, + ); + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ status: "Canceled" }))).toBe( + false, + ); + expect(shouldCompleteAfterCompletionDoc("Yes", closablePmRow({ isPastDue: true }))).toBe(false); + }); +}); 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", () => {