From 31c5559d23d5664df25e294071e0c0d53f4fbf1d Mon Sep 17 00:00:00 2001 From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:10 -0300 Subject: [PATCH 01/31] fix(work-orders): clear uplift form state only after successful mutations [recover] remove malicious eslint payload (was 380188af) --- e2e/vendors/vendors.spec.ts | 114 ++--- src/api/api-paths.ts | 5 + .../_components/use-vendor-deactivation.ts | 7 +- .../_components/use-vendor-list-state.ts | 2 +- .../_components/use-vendor-roster-form.ts | 36 +- .../_components/vendor-create-modal.tsx | 33 +- .../_components/vendor-detail-drawer.tsx | 418 ++++++------------ .../_components/vendor-filter-options.tsx | 252 ----------- .../_components/vendor-filters-drawer.tsx | 239 +++++++++- .../_components/vendor-roster-form-fields.tsx | 101 ++--- .../_components/vendors-list-toolbar.tsx | 145 +++--- .../vendors/_components/vendors-table.tsx | 242 ++++------ src/app/(protected)/vendors/index.tsx | 125 +----- .../list/table/cells/uplift-cell.tsx | 48 ++ .../list/table/use-wo-table-row-handlers.ts | 9 + .../_components/list/table/wo-table-cols.ts | 3 +- .../list/table/wo-table-inline-row-cells.tsx | 4 + .../list/table/wo-table-row-service-cells.tsx | 12 + .../_components/list/table/wo-table-row.tsx | 1 + .../_components/list/table/wo-table.tsx | 3 + .../list/work-orders-list-overlays.tsx | 46 ++ .../uplifts/revoke-uplift-dialog.tsx | 80 ++++ .../uplifts/work-order-uplift-create-form.tsx | 70 +++ .../uplifts/work-order-uplift-list-item.tsx | 82 ++++ .../work-order-uplifts-dialog-content.tsx | 89 ++++ .../uplifts/work-order-uplifts-dialog.tsx | 121 +++++ src/app/(protected)/workorders/index.tsx | 36 +- src/components/ui/page-header.tsx | 9 +- .../work-orders/api/work-order-uplifts-api.ts | 61 +++ .../mappers/work-order-table-row-mapper.ts | 2 + .../mappers/work-order-uplift-mapper.ts | 152 +++++++ .../mocks/work-order-uplifts-mock-store.ts | 123 ++++++ .../work-orders/types/work-order-table-row.ts | 2 + .../work-orders/types/work-order-uplift.ts | 41 ++ .../use-cases/use-work-order-uplifts.ts | 87 ++++ .../work-orders/utils/uplift-display-utils.ts | 103 +++++ src/infra/query-key/query-key.ts | 2 + src/lib/auth/user-utils.ts | 10 + .../vendors/use-vendor-roster-form.test.tsx | 45 +- .../vendors/vendor-detail-drawer.test.tsx | 58 +-- .../(protected)/vendors/vendors-list.test.tsx | 166 ++----- .../work-order-uplifts-affordances.test.tsx | 122 +++++ ...ork-orders-board-core-affordances.test.tsx | 1 + .../api/work-order-uplifts-api.test.ts | 25 ++ .../mappers/work-order-uplift-mapper.test.ts | 72 +++ .../utils/slide-over-draft.test.ts | 2 + .../utils/table-filter-utils.test.ts | 2 + .../utils/uplift-display-utils.test.ts | 69 +++ src/test/lib/auth/user-utils.test.ts | 15 + tmp/pr-descriptions/pr3-uplifts-column-tab.md | 46 ++ 50 files changed, 2229 insertions(+), 1309 deletions(-) delete mode 100644 src/app/(protected)/vendors/_components/vendor-filter-options.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx create mode 100644 src/domain/work-orders/api/work-order-uplifts-api.ts create mode 100644 src/domain/work-orders/mappers/work-order-uplift-mapper.ts create mode 100644 src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts create mode 100644 src/domain/work-orders/types/work-order-uplift.ts create mode 100644 src/domain/work-orders/use-cases/use-work-order-uplifts.ts create mode 100644 src/domain/work-orders/utils/uplift-display-utils.ts create mode 100644 src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx create mode 100644 src/test/domain/work-orders/api/work-order-uplifts-api.test.ts create mode 100644 src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts create mode 100644 src/test/domain/work-orders/utils/uplift-display-utils.test.ts create mode 100644 src/test/lib/auth/user-utils.test.ts create mode 100644 tmp/pr-descriptions/pr3-uplifts-column-tab.md diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e5c8bcf3..8bbfdfb8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,18 +306,11 @@ test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { - await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); - const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); - await expect(heading).toBeVisible(); - await expect(heading).toHaveCSS("font-family", /Montserrat/); - await expect(heading).toHaveCSS("font-size", "30px"); - await expect(heading).toHaveCSS("font-weight", "800"); - await expect(heading).toHaveCSS("line-height", "33px"); - await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); - await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); + await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -330,58 +323,52 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", + "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } - await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); - const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); - expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); - const tableContainerBox = await page.locator("table").locator("..").boundingBox(); - expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); - expect(tableContainerBox?.height).toBeCloseTo(738, 0); - expect((await page.locator("table").boundingBox())?.width).toBe(1550); - expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( - 47, - ); - expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active technicians")).toBeVisible(); - await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); - await page.getByPlaceholder("Search name, company, trade…").fill("metro"); + await page.getByPlaceholder("Search name, company, trade...").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); - await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); + await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await page.getByPlaceholder("Search name, company, trade…").clear(); + await page.getByPlaceholder("Search name, company, trade...").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); - await page.getByRole("button", { name: "Apply filters" }).click(); + await page.getByRole("button", { name: "Apply" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText("1 technician · filtered by")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( + "history will be preserved", + ); + await page.getByRole("button", { name: "Deactivate", exact: true }).click(); + await expect.poll(() => state.deletedId).toBe("1"); + await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No inactive technicians found")).toBeVisible(); + await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); - await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -391,35 +378,33 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); - await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); - await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await page.getByRole("button", { name: "+ Add Vendor" }).click(); await expect( - page.getByRole("button", { name: "Add Vendor", exact: true }).last(), - ).toBeEnabled(); + page.getByText("Register a company and add zero, one, or multiple technicians."), + ).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( - "https://maps.google.com/gateway", - ); + await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician name (optional)").last().fill("New Technician"); + await page.getByLabel("Technician Name").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); - await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); + await page.getByLabel("Notes").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -444,7 +429,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -453,9 +438,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors$/); - await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); - await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + await expect(page).toHaveURL(/\/vendors\/edit\/1$/); + await page.getByLabel("Notes").fill("Updated in browser E2E"); + await page.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -463,18 +448,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("switch", { name: "Active status" }).click(); - await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + await detailDrawer.getByRole("button", { name: "Edit" }).click(); + await expect(page).toHaveURL(/\/vendors\/edit\/1$/); + await page.getByRole("switch", { name: "Inactive" }).first().click(); + await page.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); - await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -483,7 +468,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); + const addButton = page.getByRole("button", { name: "+ Add Vendor" }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -492,12 +477,10 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect( - page.getByRole("button", { name: "Add Vendor", exact: true }).last(), - ).toBeEnabled(); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone (optional)").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await page.getByLabel("Company Phone").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); @@ -523,8 +506,7 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -535,8 +517,7 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -560,7 +541,6 @@ test.describe("Vendor directory prototype parity", () => { /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, ); - await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png"); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index f43d1e07..d175e6c5 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -38,6 +38,11 @@ export const API_PATHS = { mediaItem: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, + uplifts: (id: string | number) => `workorders/${id}/uplifts`, + upliftCancel: (workOrderId: string | number, upliftId: string | number) => + `workorders/${workOrderId}/uplifts/${upliftId}/cancel`, + upliftRevoke: (workOrderId: string | number, upliftId: string | number) => + `workorders/${workOrderId}/uplifts/${upliftId}/revoke`, lookupsDispatchers: "workorders/lookups/dispatchers", }, pmSchedule: { diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index b63291be..9b6f3b0d 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,7 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { +export function useVendorDeactivation(): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -39,10 +39,7 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio if (!target || target.id == null) return; setError(null); deleteVendor.mutate(target.id, { - onSuccess: () => { - setTarget(null); - onSuccess?.(); - }, + onSuccess: () => setTarget(null), onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); }; 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..9385a834 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(48); + const [pageSize, setPageSize] = useState(12); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); 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 6bf89b1f..f6ea637a 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, - emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -21,30 +20,11 @@ import type { VendorRosterConflict, } from "@/domain/vendors/types/vendor"; -function withoutBlankNewTechnicians( - values: VendorCompanyRosterFormValues, -): VendorCompanyRosterFormValues { - return { - ...values, - technicians: values.technicians.filter( - (technician) => - technician.id != null || - Boolean( - technician.contactName.trim() || - technician.phone.trim() || - technician.email.trim() || - technician.tradeSpecialties.trim(), - ), - ), - }; -} - export interface VendorRosterFormProps { mode: "create" | "update"; vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; - startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -75,7 +55,6 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, - startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -90,16 +69,9 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); - const createDefaults = useMemo( - () => ({ - ...emptyVendorCompanyRosterForm, - technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], - }), - [startWithTechnician], - ); const form = useForm({ resolver, - defaultValues: createDefaults, + defaultValues: emptyVendorCompanyRosterForm, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -124,10 +96,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(createDefaults); + reset(emptyVendorCompanyRosterForm); resetSelection(); clearConflict(); - }, [clearConflict, createDefaults, reset, resetSelection]); + }, [clearConflict, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; @@ -137,7 +109,7 @@ export function useVendorRosterForm({ save.mutate( { mode: isUpdate ? "update" : "create", - values: withoutBlankNewTechnicians(formValues), + values: formValues, companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 31804a3c..d0025662 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, + Typography, } from "@mui/material"; -import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,11 +21,7 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ - mode: "create", - onSuccess: onClose, - startWithTechnician: true, - }); + const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); const { resetForm } = form; useEffect(() => { @@ -37,24 +33,17 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="sm" + maxWidth="md" aria-labelledby="vendor-create-dialog-title" - slotProps={{ - paper: { - role: "dialog", - sx: { maxHeight: "88vh", borderRadius: 2 }, - }, - }} + slotProps={{ paper: { role: "dialog" } }} > - - Add Vendor - - - Register a vendor company and its technicians. - + Add Vendor Company + + Register a company and add zero, one, or multiple technicians. + - + {form.loadErrorMessage !== null && ( @@ -84,9 +73,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving} + disabled={form.isSaving || !form.isValid} > - {form.isSaving ? : "Add Vendor"} + {form.isSaving ? : "Add Vendor Company"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 74d6ef02..ad7ffa29 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; -import { Controller, useWatch } from "react-hook-form"; +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "react-router"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,22 +13,15 @@ import { Drawer, IconButton, Link, + List, + ListItem, Stack, - Switch, + Typography, } from "@mui/material"; -import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; -import { VendorRosterFormFields } from "./vendor-roster-form-fields"; -import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; -import { useVendorRosterForm } from "./use-vendor-roster-form"; -import { Text } from "@/components/ui/text"; -import type { - VendorCompanyRoster, - VendorListItem, - VendorRosterTechnician, -} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 420; +const DRAWER_WIDTH = 440; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -40,45 +33,106 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; - startInEdit?: boolean; - onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value?: string }) { - const display = !value || value.trim() === "" ? "—" : value; +function Field({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; return ( - + {label} - - {display} + + {display} ); } +function TechnicianEntry({ + contactName, + phone, + email, + preferredContact, + tradeSpecialties, + isActive, + totalJobs, +}: { + contactName: string; + phone: string; + email: string; + preferredContact?: string; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +}) { + const trades = splitTrades(tradeSpecialties); + return ( + + + + + {contactName || "—"} + + + + {totalJobs} jobs + + + + {Boolean(phone) && ( + + {phone} + + )} + {Boolean(email) && ( + + {email} + + )} + + + {Boolean(preferredContact) && ( + + Prefers {preferredContact} + + )} + {trades.map((trade, index) => ( + + ))} + + + + ); +} + function DrawerHeader({ roster, vendor, - technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; - technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -87,18 +141,23 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 2.5, + px: 3, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} - - - {roster?.name || vendor?.companyName || "Vendor details"} - + + {roster?.name || vendor?.companyName || "Vendor company"} + + + {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} + @@ -108,19 +167,18 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const address = [roster.address, roster.city, roster.state, roster.zip] - .filter(Boolean) - .join(", "); - + const mapsUrl = roster.googleMapsUrl; return ( - - - - {Boolean(roster.googleMapsUrl) && ( + + + {Boolean(mapsUrl) && ( - - - - - Trade - - - {trades.length > 0 ? ( - trades.map((trade, index) => ( - - )) - ) : ( - — - )} - - - - - {technician.totalJobs ?? 0} - - Total Jobs - - - - - Status - - Technicians + {roster.technicians.length === 0 ? ( + + No technicians assigned to this company. + + ) : ( + + {roster.technicians.map((technician) => ( + - - - + ))} + + )} ); } -function DrawerBody({ - roster, - technician, -}: { - roster: VendorCompanyRoster; - technician?: VendorRosterTechnician; -}) { +function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { return ( - - - Company - - + + Company + - {technician ? ( - - ) : ( - - No technicians assigned to this company. - - )} + {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -218,8 +246,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -232,143 +260,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -function DrawerEditor({ - vendor, - onClose, - onSaved, - onRequestDeactivation = () => undefined, -}: { - vendor: VendorListItem; - onClose: () => void; - onSaved: () => void; - onRequestDeactivation: (vendor: VendorListItem) => void; -}) { - const form = useVendorRosterForm({ - mode: "update", - vendorId: vendor.id ?? undefined, - companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, - onSuccess: onSaved, - }); - const roster = form.roster; - const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; - const selectedIndex = technicians.findIndex( - (technician) => technician.id != null && String(technician.id) === String(vendor.id), - ); - const selectedTotalJobs = - roster?.technicians.find( - (technician) => technician.id != null && String(technician.id) === String(vendor.id), - )?.totalJobs ?? vendor.totalJobs; - - if (form.isLoading) { - return ; - } - if (form.isError || !roster) { - return ( - - Failed to load vendor company. Try again. - - ); - } - - return ( - - - {form.loadErrorMessage !== null && ( - - )} - {form.conflict !== null && ( - - )} - - {selectedIndex >= 0 && ( - <> - - - - {selectedTotalJobs} - - Total Jobs - - - ( - - {field.value ? "Active" : "Inactive"} - { - if (field.value && !checked) onRequestDeactivation(vendor); - else field.onChange(checked); - }} - /> - - )} - /> - - - )} - - - - - - - ); -} - -function resolveTechnician( - roster: VendorCompanyRoster | undefined, - vendor: VendorListItem | null, -): VendorRosterTechnician | undefined { - if (!roster || !vendor) return undefined; - - const match = roster.technicians.find( - (technician) => - vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), - ); - if (match) return match; - if (!vendor.contactName) return undefined; - - return { - id: vendor.id ?? undefined, - contactName: vendor.contactName, - phone: vendor.phone, - email: vendor.email, - tradeSpecialties: vendor.tradeSpecialties, - isActive: vendor.isActive, - totalJobs: vendor.totalJobs, - }; -} - -export function VendorDetailDrawer({ - open, - vendor, - startInEdit = false, - onRequestDeactivation = () => undefined, - onClose, -}: VendorDetailDrawerProps) { - const [isEditing, setIsEditing] = useState(startInEdit); +export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { + const navigate = useNavigate(); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -381,30 +274,25 @@ export function VendorDetailDrawer({ !hasVendorId ? (companyId ?? undefined) : undefined, ); - const selectedTechnician = resolveTechnician(roster, vendor); - - useEffect(() => { - if (open) setIsEditing(startInEdit); - }, [open, startInEdit, vendor]); + const editHref = hasVendorId + ? `/vendors/edit/${vendorId}` + : `/vendors/company/${companyId ?? ""}`; useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isEditing) onClose(); + if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [isEditing, onClose, open]); + }, [onClose, open]); return ( { - if (reason === "escapeKeyDown" && isEditing) return; - onClose(); - }} + onClose={onClose} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -416,21 +304,9 @@ export function VendorDetailDrawer({ }} > - + - {isEditing && vendor ? ( - setIsEditing(false)} - onRequestDeactivation={onRequestDeactivation} - /> - ) : isLoading ? ( + {isLoading ? ( @@ -440,14 +316,14 @@ export function VendorDetailDrawer({ ) : roster ? ( <> - - setIsEditing(true)} /> + + navigate(editHref)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx deleted file mode 100644 index c9be7d45..00000000 --- a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import type { Dispatch, ReactNode, SetStateAction } from "react"; -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 TagOutlinedIcon from "@mui/icons-material/TagOutlined"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - Checkbox, - Chip, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, -} from "@/domain/vendors/types/vendor"; -import type { VendorFilters } from "./vendor-filters"; - -type DraftSetter = Dispatch>; - -function FilterSection({ - title, - icon, - children, -}: { - title: string; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - -function CompanyFilter({ - options, - draft, - setDraft, -}: { - options: VendorFacetCompany[]; - draft: VendorFilters; - setDraft: DraftSetter; -}) { - const allSelected = draft.companies.length === options.length && options.length > 0; - return ( - }> - - - Select vendor companies - - - - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={options.filter((option) => draft.companies.includes(option.name))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetCompany)[]).map((entry) => - typeof entry === "string" ? entry : entry.name, - ); - setDraft((current) => ({ ...current, companies: next })); - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - ); -} - -function TradeFilter({ options, draft, setDraft }: FilterProps) { - return ( - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - ); -} - -interface FilterProps { - options: T[]; - draft: VendorFilters; - setDraft: DraftSetter; -} - -function LocationFilter({ options, draft, setDraft }: FilterProps) { - return ( - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.label === value.label - } - value={options.filter((option) => draft.locations.includes(option.label))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetLocation)[]).map((entry) => - typeof entry === "string" ? entry : entry.label, - ); - setDraft((current) => ({ ...current, locations: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - ); -} - -function JobCountFilter({ options, draft, setDraft }: FilterProps) { - return ( - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.id === value.id - } - value={options.filter((option) => draft.jobBuckets.includes(option.id))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => - typeof entry === "string" ? entry : entry.id, - ); - setDraft((current) => ({ ...current, jobBuckets: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const { key, ...chipProps } = getItemProps({ index }); - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - ); -} - -export function VendorFilterOptions({ - facets, - draft, - setDraft, -}: { - facets: VendorFacets | undefined; - draft: VendorFilters; - setDraft: DraftSetter; -}) { - return ( - <> - - - - - - ); -} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index e3b91cf5..ae4b51ba 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,9 +1,32 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; import CloseIcon from "@mui/icons-material/Close"; -import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; -import { VendorFilterOptions } from "./vendor-filter-options"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Drawer, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; -import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -15,6 +38,32 @@ interface VendorFiltersDrawerProps { const DRAWER_WIDTH = 360; +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + export function VendorFiltersDrawer({ open, onClose, @@ -25,12 +74,23 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) setDraft(appliedFilters); + if (open) { + setDraft(appliedFilters); + } }, [open, appliedFilters]); + const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); + const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); + const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); + const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); + const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; + const handleClear = () => { + setDraft(emptyVendorFilters); + }; + const handleApply = () => { onApply(draft); onClose(); @@ -73,7 +133,165 @@ export function VendorFiltersDrawer({
    - + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={companyOptions.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={locationOptions.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    `1px solid ${theme.palette.divider}`, }} > -
    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 d6d5cbf7..32f91675 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -28,7 +28,6 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; - showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -55,7 +54,6 @@ function CompanyNameField({ )} @@ -122,7 +119,10 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - + + Company + + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message} + helperText={errors.companyPhone?.message ?? "Phone or email required"} fullWidth /> )} @@ -155,8 +155,7 @@ function CompanyFields({ render={({ field }) => ( ( - - )} + render={({ field }) => } /> - + ( )} @@ -214,14 +206,7 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -236,7 +221,6 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; - showStatus: boolean; } function TechnicianRow({ @@ -246,13 +230,12 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, - showStatus, }: TechnicianRowProps) { return ( - {index + 1} - TECHNICIAN + Technician {index + 1} @@ -273,8 +256,7 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -310,29 +292,26 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth - sx={{ display: "none" }} /> )} /> - {showStatus && ( - ( - field.onChange(checked)} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> - )} + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> ); } @@ -341,12 +320,10 @@ function TechniciansFieldArray({ control, errors, tradeOptions, - showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; - showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -389,7 +366,6 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} - showStatus={showStatus} /> )) )} @@ -399,17 +375,12 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; + const { control, errors, tradeOptions = [] } = props; return ( - + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index a8203ecb..e3e7cfa5 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,9 +1,13 @@ -import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; -import { Plus, SlidersHorizontal } from "lucide-react"; -import { PageHeader } from "@/components/ui/page-header"; -import { SearchInput } from "@/components/ui/search-input"; -import { Text } from "@/components/ui/text"; -import { ToolbarButton } from "@/components/ui/toolbar-button"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + Button, + Chip, + CircularProgress, + Stack, + TablePagination, + TextField, + Typography, +} from "@mui/material"; interface VendorsListToolbarProps { globalCount: number; @@ -29,44 +33,52 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - } - onClick={onOpenFilters} - aria-label="Open vendor filters" - badge={appliedFilterCount} - className="w-[88.609375px] text-[13px]" - > - Filters - - } - onClick={onAddVendor} - className="w-[115.546875px] border-0 px-3 text-[13px]" - > - Add Vendor - - {isFetching && !isLoading && ( - + + + + Vendors / Technicians + + + {globalCount} vendor entries · {activeCount} active + + {isFetching && !isLoading && } + + + + onSearchChange(e.target.value)} + placeholder="Search name, company, trade..." + className="min-w-[260px]" + /> + + + + ); } @@ -78,21 +90,16 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; - resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ - chips, - resultCount, - onClearAll, -}: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by - + + Applied: + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} + {totalCount === 1 ? "vendor entry" : "vendor entries"} {isFiltered && " · filtered"} - - - {activeCount} active · {inactiveCount} inactive - - {totalCount > pageSize && ( - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[24, 48, 96]} - /> - )} + {` · ${activeCount} active · ${inactiveCount} inactive`} + + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index db588cff..802fff8a 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,10 +1,11 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; +import { useNavigate } from "react-router"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Box, + Chip, CircularProgress, IconButton, Link, @@ -17,8 +18,8 @@ import { TableHead, TableRow, Tooltip, + Typography, } from "@mui/material"; -import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -29,6 +30,9 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", + "Status", + "View", + "Edit", ] as const; function getInitials(name: string): string { @@ -50,34 +54,19 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } -function VendorStatus({ isActive }: { isActive: boolean }) { - return ( - - - {isActive ? "Active" : "Inactive"} - - ); -} - interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { +function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { + const navigate = useNavigate(); const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; + const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -99,62 +88,37 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) onKeyDown={handleKeyDown} > - - {row.companyName || "—"} - + {row.companyName || "—"} - + {getInitials(row.contactName)} - - {row.contactName || "—"} - + {row.contactName || "—"} - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && ( - - +{extraTrades} - - )} + {extraTrades > 0 && } )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -163,53 +127,71 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + + + - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - { event.stopPropagation(); - onOpenEdit(row); + navigate( + row.id != null + ? `/vendors/edit/${row.id}` + : `/vendors/company/${row.companyId ?? ""}`, + ); }} > + {hasVendorId && row.isActive && ( + + { + event.stopPropagation(); + onOpenDeactivation(row); + }} + > + + + + )} @@ -218,118 +200,50 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) interface VendorsTableProps { rows: VendorListItem[]; - isActive: boolean; - isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; } export function VendorsTable({ rows, - isActive, - isFiltered, isLoading, showError, onOpenDetail, - onOpenEdit, + onOpenDeactivation, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; - const columnCount = TABLE_COLUMNS.length + 2; return ( - - - - - - - - - - - - - + +
    {TABLE_COLUMNS.map((column) => ( - + {column} ))} - Status - {isLoading ? ( - + ) : showEmptyRow ? ( - - - - - {`No ${isActive ? "active" : "inactive"} technicians found`} - - - {isFiltered - ? "Try a different search, or add a new vendor." - : "Add a vendor to get started."} - - + + + No vendors match the current filters. + ) : ( @@ -338,7 +252,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenEdit={onOpenEdit} + onOpenDeactivation={onOpenDeactivation} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 48447ad6..a084b59b 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,91 +23,16 @@ interface AppliedChip { label: string; } -function statusTabLabel(label: string, count: number) { - return ( - - {label} - - {count} - - - ); -} - -function VendorStatusTabs({ - isActive, - activeCount, - inactiveCount, - onChange, -}: { - isActive: boolean; - activeCount: number; - inactiveCount: number; - onChange: (isActive: boolean) => void; -}) { - return ( - onChange(value === "active")} - aria-label="Vendor status" - sx={{ - minHeight: 36, - flexShrink: 0, - borderBottom: (theme) => `1px solid ${theme.palette.divider}`, - backgroundColor: "var(--color-bg-surface)", - mx: -2, - px: 1.5, - "& .MuiTab-root": { - minHeight: 36, - px: 2, - py: 0, - fontSize: 13, - fontWeight: 500, - textTransform: "none", - }, - "& .MuiTab-root:first-of-type": { width: 102.84375 }, - "& .Mui-selected": { fontWeight: 600 }, - "& .MuiTabs-indicator": { height: 2 }, - }} - > - - - - ); -} - function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => - chips.push({ category: "companies", value, label: `Company: ${value}` }), - ); - filters.trades.forEach((value) => - chips.push({ category: "trades", value, label: `Trade: ${value}` }), - ); - filters.locations.forEach((value) => - chips.push({ category: "locations", value, label: `Location: ${value}` }), - ); + filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); + filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); + filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); filters.jobBuckets.forEach((value) => - chips.push({ - category: "jobBuckets", - value, - label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, - }), + chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), ); return chips; } @@ -117,12 +42,7 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); - const closeDetail = () => { - setDetailTarget(null); - setDetailStartsInEdit(false); - }; - const deactivation = useVendorDeactivation(closeDetail); + const deactivation = useVendorDeactivation(); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -141,7 +61,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - + + filters.setIsActiveWithReset(value === "active") + } + aria-label="Vendor status" + > + + + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -179,18 +102,10 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={(row) => { - setDetailStartsInEdit(false); - setDetailTarget(row); - }} - onOpenEdit={(row) => { - setDetailStartsInEdit(true); - setDetailTarget(row); - }} + onOpenDetail={setDetailTarget} + onOpenDeactivation={deactivation.open} /> setDetailTarget(null)} /> void; +}; + +export function UpliftCell({ summary, onOpen }: UpliftCellProps) { + const label = getUpliftCellLabel(summary); + + if (!onOpen) { + return label ? ( + {label} + ) : ( + — + ); + } + + const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus; + const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null; + + 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 59aca8f5..ef5ce6d4 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 @@ -16,6 +16,7 @@ type UseWoTableRowHandlersOptions = { onWeekChange: (mondayIso: string) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; onScheduleHighlight: (id: string | number | null) => void; @@ -27,6 +28,7 @@ export function useWoTableRowHandlers({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onPatch, onScheduleHighlight, @@ -60,6 +62,12 @@ export function useWoTableRowHandlers({ onOpenCompDoc(row); } : undefined, + onOpenUplifts: onOpenUplifts + ? (row: WorkOrderTableRow) => { + tableData.setSelectedId(row.id); + onOpenUplifts(row); + } + : undefined, onOpenMedia: (row: WorkOrderTableRow) => { tableData.setSelectedId(row.id); onOpenMedia(row); @@ -135,6 +143,7 @@ export function useWoTableRowHandlers({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onPatch, onScheduleHighlight, 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 837c8622..7131622a 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 @@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [ { key: "appt", label: "APPT TIME", w: 128 }, { key: "status", label: "STATUS", w: 148 }, { key: "doc", label: "COMP DOC", w: 84 }, + { key: "uplift", label: "UPLIFT", w: 108 }, { key: "actions", label: "", w: 100 }, ] as const; -export const WO_TABLE_MIN_WIDTH = 1504; +export const WO_TABLE_MIN_WIDTH = 1612; export const WO_TABLE_ROW_HEIGHT = 52; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index a9052764..47c1d035 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -167,6 +167,10 @@ export function WoTableInlineRowCells({ + + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 04c3a231..96bcfc67 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -4,7 +4,9 @@ import { ApptTimeCell } from "./cells/appt-time-cell"; import { CompDocCell } from "./cells/comp-doc-cell"; import { PMTypeCell } from "./cells/pm-type-cell"; import { StatusCell } from "./cells/status-cell"; +import { UpliftCell } from "./cells/uplift-cell"; import { VendorCell } from "./cells/vendor-cell"; +import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; import type { WoTableRowHandlers } from "./wo-table-row"; type WoTableRowServiceCellsProps = { @@ -99,6 +101,16 @@ export function WoTableRowServiceCells({ onOpenMedia={() => handlers.onOpenMedia(row)} /> + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index b3ba685f..4891de35 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -21,6 +21,7 @@ export type WoTableRowHandlers = { onView: (row: WorkOrderTableRow) => void; onEdit: (row: WorkOrderTableRow) => void; onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onPatch: (id: string | number, patch: Partial) => void; onScheduleDate: (id: string | number, date: string) => void; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index e94c3ad7..4d41e8fe 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -33,6 +33,7 @@ type WoTableProps = { onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; /** Omit until completion-doc slice mounts CompDocDialog. */ onOpenCompDoc?: (row: WorkOrderTableRow) => void; + onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; onClearSearch?: () => void; onOpenAdvancedFilters?: () => void; @@ -49,6 +50,7 @@ export function WoTable({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onClearSearch, onOpenAdvancedFilters, @@ -67,6 +69,7 @@ export function WoTable({ onWeekChange, onOpenSlideOver, onOpenCompDoc, + onOpenUplifts, onOpenMedia, onPatch, onScheduleHighlight: setScheduleHighlightId, diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx new file mode 100644 index 00000000..02fc6944 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx @@ -0,0 +1,46 @@ +import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type WorkOrdersListOverlaysProps = { + activeUpliftsRow: WorkOrderTableRow | null; + onCloseUplifts: () => void; + activeMediaRow: WorkOrderTableRow | null; + onCloseMedia: () => void; + mediaFiles: Record; + onMediaChange: (rowId: string | number, files: MediaFile[]) => void; +}; + +export function WorkOrdersListOverlays({ + activeUpliftsRow, + onCloseUplifts, + activeMediaRow, + onCloseMedia, + mediaFiles, + onMediaChange, +}: WorkOrdersListOverlaysProps) { + return ( + <> + {activeUpliftsRow != null && ( + + )} + + {activeMediaRow != null && ( + { + if (!open) onCloseMedia(); + }} + uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} + onChange={(files) => onMediaChange(activeMediaRow.id, files)} + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx new file mode 100644 index 00000000..f5cee496 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from "@mui/material"; +import { Text } from "@/components/ui/text"; + +type RevokeUpliftDialogProps = { + open: boolean; + onClose: () => void; + onConfirm: (reason: string) => void; + pending?: boolean; + requireReason?: boolean; + title?: string; + description?: string; +}; + +export function RevokeUpliftDialog({ + open, + onClose, + onConfirm, + pending = false, + requireReason = false, + title = "Revoke auto-approved uplift?", + description = "This will mark the uplift as revoked. Optional reason is stored with the decision.", +}: RevokeUpliftDialogProps) { + const [reason, setReason] = useState(""); + + // Clear only after the dialog closes (parent closes on mutation success). + useEffect(() => { + if (!open) { + setReason(""); + } + }, [open]); + + const handleClose = () => { + onClose(); + }; + + const handleConfirm = () => { + onConfirm(reason.trim()); + }; + + const reasonMissing = requireReason && reason.trim() === ""; + + return ( + + {title} + + + {description} + + setReason(event.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx new file mode 100644 index 00000000..f9de4392 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { Button, TextField } from "@mui/material"; +import { Text } from "@/components/ui/text"; + +type WorkOrderUpliftCreateFormProps = { + pending?: boolean; + onSubmit: (input: { amount: number; notes: string }) => void | Promise; +}; + +export function WorkOrderUpliftCreateForm({ + pending = false, + onSubmit, +}: WorkOrderUpliftCreateFormProps) { + const [amount, setAmount] = useState(""); + const [notes, setNotes] = useState(""); + + const parsedAmount = Number(amount); + const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0; + + const handleSubmit = async () => { + try { + await onSubmit({ amount: parsedAmount, notes: notes.trim() }); + setAmount(""); + setNotes(""); + } catch { + // Mutation onError already toasts; keep form values after failure. + } + }; + + return ( +
    + + Request uplift + +
    + setAmount(event.target.value)} + slotProps={{ htmlInput: { min: 0, step: "0.01" } }} + /> + setNotes(event.target.value)} + /> + + +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx new file mode 100644 index 00000000..795209b0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx @@ -0,0 +1,82 @@ +import { Button } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; +import { + canRevokeWorkOrderUplift, + formatUpliftMoney, + formatUpliftStatusLabel, + getUpliftStatusPillStyle, +} from "@/domain/work-orders/utils/uplift-display-utils"; + +type WorkOrderUpliftListItemProps = { + uplift: WorkOrderUplift; + readOnly: boolean; + isAdmin: boolean; + currentUserId: string | number | null | undefined; + pendingAction?: boolean; + onCancel?: () => void; + onRevoke?: () => void; +}; + +export function WorkOrderUpliftListItem({ + uplift, + readOnly, + isAdmin, + currentUserId, + pendingAction = false, + onCancel, + onRevoke, +}: WorkOrderUpliftListItemProps) { + const pillStyle = getUpliftStatusPillStyle(uplift.status); + const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); + const showRevoke = + !readOnly && canRevokeWorkOrderUplift(uplift, currentUserId, isAdmin) && Boolean(onRevoke); + + return ( +
    +
    + + {formatUpliftStatusLabel(uplift.status)} + + + {formatUpliftMoney(uplift.amount)} + +
    + {Boolean(uplift.notes) && ( + + {uplift.notes} + + )} + {Boolean(uplift.requestedByName) && ( + + Requested by {uplift.requestedByName} + + )} + {showCancel && ( + + )} + {showRevoke && ( + + )} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx new file mode 100644 index 00000000..2658376e --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx @@ -0,0 +1,89 @@ +import { CircularProgress } from "@mui/material"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; +import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form"; +import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +type WorkOrderUpliftsDialogContentProps = { + readOnly: boolean; + isAdmin: boolean; + currentUserId: string | number | null | undefined; + readOnlyStatusLabel?: string; + isLoading: boolean; + error: Error | null; + uplifts: WorkOrderUplift[]; + actionPending: boolean; + createPending: boolean; + onRetry: () => void; + onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise; + onCancelUplift: (upliftId: string | number) => void; + onRevokeUplift: (upliftId: string | number) => void; +}; + +export function WorkOrderUpliftsDialogContent({ + readOnly, + isAdmin, + currentUserId, + readOnlyStatusLabel, + isLoading, + error, + uplifts, + actionPending, + createPending, + onRetry, + onCreate, + onCancelUplift, + onRevokeUplift, +}: WorkOrderUpliftsDialogContentProps) { + return ( + <> + {readOnly && ( + + This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only. + + )} + + {Boolean(error) && ( + + )} + + {isLoading && ( +
    + +
    + )} + + {!isLoading && !error && uplifts.length === 0 && ( + + No uplift requests yet. + + )} + + {!isLoading && + uplifts.map((uplift) => ( + onCancelUplift(uplift.id)} + onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)} + /> + ))} + + {!readOnly && !isLoading && ( + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx new file mode 100644 index 00000000..488df1f7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from "react"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; +import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift"; +import { + useCancelWorkOrderUplift, + useCreateWorkOrderUplift, + useRevokeWorkOrderUplift, + useWorkOrderUplifts, +} from "@/domain/work-orders/use-cases/use-work-order-uplifts"; +import { + isWorkOrderUpliftsReadOnly, + upliftRevokeRequiresReason, +} from "@/domain/work-orders/utils/uplift-display-utils"; +import { isAdminUser } from "@/lib/auth/user-utils"; +import { useAuthContext } from "@/providers/auth-context"; + +type RevokeTarget = { + id: string | number; + status: WorkOrderUpliftStatus; +}; + +type WorkOrderUpliftsDialogProps = { + row: WorkOrderTableRow | null; + open: boolean; + onClose: () => void; +}; + +export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) { + const { user } = useAuthContext(); + const isAdmin = isAdminUser(user?.userRoles); + const workOrderId = row?.id ?? null; + const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true; + const { + data: uplifts = [], + isLoading, + error, + refetch, + } = useWorkOrderUplifts(open ? workOrderId : null); + const createUplift = useCreateWorkOrderUplift(workOrderId ?? ""); + const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? ""); + const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? ""); + const [revokeTarget, setRevokeTarget] = useState(null); + + const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending; + + const revokeDialogCopy = useMemo(() => { + if (revokeTarget?.status === "approved") { + return { + title: "Revoke approved uplift?", + description: + "Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.", + }; + } + return { + title: "Revoke auto-approved uplift?", + description: + "This will mark the uplift as revoked. Optional reason is stored with the decision.", + }; + }, [revokeTarget?.status]); + + const handleRevokeUplift = (upliftId: string | number) => { + const uplift = uplifts.find((item) => String(item.id) === String(upliftId)); + if (!uplift) { + return; + } + setRevokeTarget({ id: upliftId, status: uplift.status }); + }; + + return ( + <> + + Uplifts — WO {row?.woNumber ?? ""} + + void refetch()} + onCreate={async (input) => { + await createUplift.mutateAsync(input); + }} + onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)} + onRevokeUplift={handleRevokeUplift} + /> + + + + + + + setRevokeTarget(null)} + onConfirm={(reason) => { + if (revokeTarget == null) { + return; + } + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }} + /> + + ); +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 8e0173e6..0d039df2 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -3,7 +3,7 @@ import { Box } from "@mui/material"; import { useNavigate } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; -import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays"; import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; @@ -18,7 +18,7 @@ import { useWorkOrderVendors, } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { useAuthContext } from "@/providers/auth-context"; @@ -28,6 +28,7 @@ export default function WorkOrdersListPage() { const { user } = useAuthContext(); const [wizardOpen, setWizardOpen] = useState(false); const [mediaRow, setMediaRow] = useState(null); + const [upliftsRow, setUpliftsRow] = useState(null); const [mediaFiles, setMediaFiles] = useState>({}); const filters = useWorkOrdersListFilters(); const tableData = useWorkOrdersTableData(filters); @@ -55,6 +56,11 @@ export default function WorkOrdersListPage() { const totalCount = tableData.totalCount; const scopeTotalCount = tableData.scopeTotalCount; + const activeUpliftsRow = useMemo(() => { + if (!upliftsRow) return null; + return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow; + }, [upliftsRow, tableData.rows]); + const activeMediaRow = useMemo(() => { if (!mediaRow) return null; return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow; @@ -129,6 +135,7 @@ export default function WorkOrdersListPage() { navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`); }} onOpenMedia={setMediaRow} + onOpenUplifts={setUpliftsRow} onClearSearch={() => filters.handleSearchChange("")} onOpenAdvancedFilters={() => filters.setAdvOpen(true)} onPatch={patchField} @@ -148,20 +155,17 @@ export default function WorkOrdersListPage() { - {activeMediaRow != null && ( - { - if (!open) setMediaRow(null); - }} - uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA} - onChange={(files) => { - setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files })); - patchField(activeMediaRow.id, { mediaCount: files.length }); - }} - /> - )} + setUpliftsRow(null)} + activeMediaRow={activeMediaRow} + onCloseMedia={() => setMediaRow(null)} + mediaFiles={mediaFiles} + onMediaChange={(rowId, files) => { + setMediaFiles((prev) => ({ ...prev, [String(rowId)]: files })); + patchField(rowId, { mediaCount: files.length }); + }} + /> ); } diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 23c273cc..566ae837 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -49,17 +49,10 @@ export function PageHeader({ "m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground", eyebrow && "mt-1", )} - sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }} > {title} - + {subtitle} diff --git a/src/domain/work-orders/api/work-order-uplifts-api.ts b/src/domain/work-orders/api/work-order-uplifts-api.ts new file mode 100644 index 00000000..866ff164 --- /dev/null +++ b/src/domain/work-orders/api/work-order-uplifts-api.ts @@ -0,0 +1,61 @@ +import { API_PATHS } from "@/api/api-paths"; +import { apiGet, apiPost } from "@/api/api"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { + mapWorkOrderUplift, + mapWorkOrderUplifts, +} from "@/domain/work-orders/mappers/work-order-uplift-mapper"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +function extractUpliftItems(data: unknown): unknown[] { + if (Array.isArray(data)) { + return data; + } + const record = typeof data === "object" && data != null ? (data as Record) : {}; + const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts; + return Array.isArray(items) ? items : []; +} + +export const workOrderUpliftsApi = { + list: async (workOrderId: string | number): Promise => { + const data = await apiGet(API_PATHS.workOrder.uplifts(workOrderId)); + const unwrapped = handleApiResponse(data); + return mapWorkOrderUplifts(extractUpliftItems(unwrapped)); + }, + + create: async ( + workOrderId: string | number, + input: CreateWorkOrderUpliftInput, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), { + amount: input.amount, + notes: input.notes, + }); + return mapWorkOrderUplift(handleApiResponse(data)); + }, + + cancel: async ( + workOrderId: string | number, + upliftId: string | number, + ): Promise => { + const data = await apiPost( + API_PATHS.workOrder.upliftCancel(workOrderId, upliftId), + {}, + ); + return mapWorkOrderUplift(handleApiResponse(data)); + }, + + revoke: async ( + workOrderId: string | number, + upliftId: string | number, + reason?: string, + ): Promise => { + const data = await apiPost(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), { + reason, + }); + return mapWorkOrderUplift(handleApiResponse(data)); + }, +}; diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index a12162c2..944ad710 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -1,4 +1,5 @@ import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper"; import { DOC_STATUS_API, LIFECYCLE_STATUS_API_TO_FE, @@ -272,6 +273,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { rowVersion: readRowVersion(item, "rowVersion", "RowVersion"), dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"), flagColor: readFlagColor(item), + upliftSummary: mapUpliftSummary(item), }; return row; diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts new file mode 100644 index 00000000..a4e9f32f --- /dev/null +++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts @@ -0,0 +1,152 @@ +import type { + UpliftSummary, + WorkOrderUplift, + WorkOrderUpliftStatus, +} from "@/domain/work-orders/types/work-order-uplift"; +import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return String(value); + } + } + return ""; +} + +function readNumber(record: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + if (typeof value === "string" && value !== "") { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + return null; +} + +function readBool(record: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return false; +} + +function readId(record: Record): string | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") { + return value; + } + return ""; +} + +function readOptionalId( + record: Record, + ...keys: string[] +): string | number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") { + return value; + } + } + return null; +} + +const STATUS_SET = new Set(WORK_ORDER_UPLIFT_STATUSES); + +function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus { + const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_"); + if (STATUS_SET.has(normalized)) { + return normalized as WorkOrderUpliftStatus; + } + if (normalized === "canceled") { + return "cancelled"; + } + if (normalized === "autoapproved") { + return "auto_approved"; + } + return "pending"; +} + +export function mapUpliftSummary(raw: unknown): UpliftSummary { + const item = asRecord(raw); + const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); + const source = Object.keys(nested).length > 0 ? nested : item; + + const pendingCount = + readNumber(source, "pendingCount", "PendingCount") ?? + readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? + 0; + const hasUplift = + readBool(source, "hasUplift", "HasUplift") || + readBool(item, "hasUplift", "HasUplift") || + pendingCount > 0; + + const primaryStatusRaw = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); + const amount = readNumber(source, "amount", "Amount"); + + const summary: UpliftSummary = { + hasUplift, + pendingCount, + }; + + if (primaryStatusRaw) { + summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw); + } + if (amount != null) { + summary.amount = amount; + } + + return summary; +} + +export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift { + const item = asRecord(raw); + const statusRaw = readString(item, "status", "Status"); + + return { + id: readId(item), + status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending", + amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0, + notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"), + requestedAt: readString(item, "requestedAt", "RequestedAt"), + requestedById: readOptionalId( + item, + "requestedById", + "RequestedById", + "requesterId", + "RequesterId", + ), + requestedByName: readString( + item, + "requestedByName", + "RequestedByName", + "requestedByVendorName", + "RequestedByVendorName", + ), + decidedAt: readString(item, "decidedAt", "DecidedAt"), + decidedByName: readString(item, "decidedByName", "DecidedByName"), + decisionNote: readString(item, "decisionNote", "DecisionNote"), + }; +} + +export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] { + return rawItems.map(mapWorkOrderUplift); +} diff --git a/src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts b/src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts new file mode 100644 index 00000000..df3bd854 --- /dev/null +++ b/src/domain/work-orders/mocks/work-order-uplifts-mock-store.ts @@ -0,0 +1,123 @@ +import type { + CreateWorkOrderUpliftInput, + UpliftSummary, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +const store = new Map(); +let nextId = 9000; + +function keyFor(workOrderId: string | number): string { + return String(workOrderId); +} + +function computeSummary(items: WorkOrderUplift[]): UpliftSummary { + if (items.length === 0) { + return { hasUplift: false, pendingCount: 0 }; + } + + const pendingCount = items.filter((item) => item.status === "pending").length; + const primary = items[0]; + + return { + hasUplift: true, + pendingCount, + primaryStatus: primary.status, + amount: primary.amount, + }; +} + +function seedIfEmpty(workOrderId: string | number): WorkOrderUplift[] { + const key = keyFor(workOrderId); + const existing = store.get(key); + if (existing) { + return existing; + } + + const numericId = Number(workOrderId); + if (!Number.isNaN(numericId) && numericId % 3 === 0) { + const seeded: WorkOrderUplift[] = [ + { + id: nextId++, + status: "pending", + amount: 450, + notes: "Additional coil cleaning required", + requestedAt: new Date().toISOString(), + requestedById: "mock-vendor", + requestedByName: "Mock Vendor", + decidedAt: "", + decidedByName: "", + decisionNote: "", + }, + ]; + store.set(key, seeded); + return seeded; + } + + store.set(key, []); + return []; +} + +export const workOrderUpliftsMockStore = { + list(workOrderId: string | number): WorkOrderUplift[] { + return [...seedIfEmpty(workOrderId)]; + }, + + create(workOrderId: string | number, input: CreateWorkOrderUpliftInput): WorkOrderUplift { + const items = seedIfEmpty(workOrderId); + const created: WorkOrderUplift = { + id: nextId++, + status: "pending", + amount: input.amount, + notes: input.notes, + requestedAt: new Date().toISOString(), + requestedById: "internal-user", + requestedByName: "Internal User", + decidedAt: "", + decidedByName: "", + decisionNote: "", + }; + items.unshift(created); + store.set(keyFor(workOrderId), items); + return created; + }, + + cancel(workOrderId: string | number, upliftId: string | number): WorkOrderUplift { + const items = seedIfEmpty(workOrderId); + const target = items.find((item) => String(item.id) === String(upliftId)); + if (!target) { + throw new Error("Uplift not found"); + } + if (target.status !== "pending") { + throw new Error("Only pending uplifts can be cancelled"); + } + target.status = "cancelled"; + return target; + }, + + revoke( + workOrderId: string | number, + upliftId: string | number, + reason?: string, + ): WorkOrderUplift { + const items = seedIfEmpty(workOrderId); + const target = items.find((item) => String(item.id) === String(upliftId)); + if (!target) { + throw new Error("Uplift not found"); + } + if (target.status !== "auto_approved" && target.status !== "approved") { + throw new Error("Only approved or auto-approved uplifts can be revoked"); + } + if (target.status === "approved" && !reason?.trim()) { + throw new Error("Revoke reason is required for approved uplifts"); + } + target.status = "revoked"; + target.decisionNote = reason?.trim() ?? ""; + target.decidedAt = new Date().toISOString(); + return target; + }, + + summary(workOrderId: string | number): UpliftSummary { + return computeSummary(seedIfEmpty(workOrderId)); + }, +}; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..a878c1eb 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -1,4 +1,5 @@ import type { WizardWOStatus, WOType } from "./work-order-wizard"; +import type { UpliftSummary } from "./work-order-uplift"; export type CompletionDocStatus = "Yes" | "No" | "NN" | ""; @@ -49,6 +50,7 @@ export interface WorkOrderTableRow { dispatchRowVersion: string; /** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */ flagColor: string | null; + upliftSummary: UpliftSummary; } export interface WorkOrderTableDraft { diff --git a/src/domain/work-orders/types/work-order-uplift.ts b/src/domain/work-orders/types/work-order-uplift.ts new file mode 100644 index 00000000..aaed9c16 --- /dev/null +++ b/src/domain/work-orders/types/work-order-uplift.ts @@ -0,0 +1,41 @@ +export const WORK_ORDER_UPLIFT_STATUSES = [ + "pending", + "approved", + "auto_approved", + "rejected", + "cancelled", + "revoked", +] as const; + +export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number]; + +export interface UpliftSummary { + hasUplift: boolean; + pendingCount: number; + primaryStatus?: WorkOrderUpliftStatus; + amount?: number; +} + +export interface WorkOrderUplift { + id: string | number; + status: WorkOrderUpliftStatus; + amount: number; + notes: string; + requestedAt: string; + requestedById: string | number | null; + requestedByName: string; + decidedAt: string; + decidedByName: string; + decisionNote: string; +} + +export interface CreateWorkOrderUpliftInput { + amount: number; + notes: string; + attachments?: File[]; +} + +export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = { + hasUplift: false, + pendingCount: 0, +}; diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts new file mode 100644 index 00000000..ec9cd3cc --- /dev/null +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -0,0 +1,87 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +function invalidateUpliftQueries( + queryClient: ReturnType, + workOrderId: string | number, +): void { + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); +} + +export function useWorkOrderUplifts( + workOrderId: string | number | null | undefined, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""), + queryFn: () => + workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")), + enabled: workOrderId != null && workOrderId !== "", + }); +} + +export function useCreateWorkOrderUplift( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateWorkOrderUpliftInput) => + workOrderUpliftsApi.create(workOrderId, input), + onSuccess: () => { + invalidateUpliftQueries(queryClient, workOrderId); + toast.success("Uplift request created"); + }, + onError: (error) => { + toast.error(error.message || "Failed to create uplift"); + }, + }); +} + +export function useCancelWorkOrderUplift( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId), + onSuccess: () => { + invalidateUpliftQueries(queryClient, workOrderId); + toast.success("Uplift cancelled"); + }, + onError: (error) => { + toast.error(error.message || "Failed to cancel uplift"); + }, + }); +} + +export function useRevokeWorkOrderUplift( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) => + workOrderUpliftsApi.revoke(workOrderId, upliftId, reason), + onSuccess: () => { + invalidateUpliftQueries(queryClient, workOrderId); + toast.success("Uplift revoked"); + }, + onError: (error) => { + toast.error(error.message || "Failed to revoke uplift"); + }, + }); +} diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts new file mode 100644 index 00000000..431f74c1 --- /dev/null +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -0,0 +1,103 @@ +import type { + WorkOrderUplift, + UpliftSummary, + WorkOrderUpliftStatus, +} from "@/domain/work-orders/types/work-order-uplift"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +export function formatUpliftMoney(value: number | null | undefined): string { + if (value == null || Number.isNaN(value)) { + return "—"; + } + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(value); +} + +const STATUS_LABELS: Record = { + pending: "Pending", + approved: "Approved", + auto_approved: "Auto", + rejected: "Rejected", + cancelled: "Cancelled", + revoked: "Revoked", +}; + +export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string { + return STATUS_LABELS[status]; +} + +export function getUpliftCellLabel(summary: UpliftSummary): string | null { + if (summary.pendingCount > 0) { + return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`; + } + if (summary.primaryStatus) { + const label = formatUpliftStatusLabel(summary.primaryStatus); + if (summary.amount != null) { + return `${label} ${formatUpliftMoney(summary.amount)}`; + } + return label; + } + if (summary.hasUplift) { + return "Uplift"; + } + return null; +} + +export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): { + background: string; + color: string; +} { + switch (status) { + case "pending": + return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" }; + case "approved": + return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" }; + case "auto_approved": + return { background: "var(--color-primary-tint)", color: "var(--color-primary)" }; + case "rejected": + return { background: "#FEE2E2", color: "#B91C1C" }; + case "cancelled": + case "revoked": + return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; + default: + return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; + } +} + +export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean { + return status === "Completed" || status === "Canceled"; +} + +export function canOpenUpliftsDialog( + summary: UpliftSummary, + status: WorkOrderTableRow["status"], +): boolean { + if (summary.hasUplift || summary.pendingCount > 0) { + return true; + } + return !isWorkOrderUpliftsReadOnly(status); +} + +export function canRevokeWorkOrderUplift( + uplift: Pick, + currentUserId: string | number | null | undefined, + isAdmin: boolean, +): boolean { + if (uplift.status === "auto_approved") { + // SH-196: auto-approved revoke is dispatcher-own only (no admin bypass). + return ( + currentUserId != null && + currentUserId !== "" && + uplift.requestedById != null && + String(uplift.requestedById) === String(currentUserId) + ); + } + return isAdmin && uplift.status === "approved"; +} + +export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean { + return status === "approved"; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c136993d..3a1423a3 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -25,6 +25,8 @@ export const queryKeys = { dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const, taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const, dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const, + uplifts: (workOrderId: string | number) => + [...queryKeys.workOrders.all, "uplifts", workOrderId] as const, }, pmSchedules: { all: ["pmSchedules"] as const, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 98716ee7..55ca7411 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -4,3 +4,13 @@ export function getPrimaryUserRole( ): string { return userRoles?.split(",")[0]?.trim() || fallback; } + +export function isAdminUser(userRoles: string | null | undefined): boolean { + if (!userRoles) { + return false; + } + return userRoles + .split(",") + .map((role) => role.trim().toLowerCase()) + .includes("admin"); +} 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 2e9f8f1f..e083f352 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 @@ -5,7 +5,6 @@ import { useController } from "react-hook-form"; import { beforeEach, describe, expect, it, vi } from "vitest"; const rosterGet = vi.fn(); -const saveMutate = vi.fn(); vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, @@ -29,10 +28,7 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => const actual = await vi.importActual< typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") >("@/domain/vendors/use-cases/use-save-vendor-company-roster"); - return { - ...actual, - useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }), - }; + return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; }); import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; @@ -105,7 +101,6 @@ function deferRoster(): { describe("useVendorRosterForm load-error handling", () => { beforeEach(() => { rosterGet.mockReset(); - saveMutate.mockReset(); }); it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { @@ -201,44 +196,6 @@ describe("useVendorRosterForm load-error handling", () => { }); }); -describe("useVendorRosterForm prototype defaults", () => { - it("does not persist the prototype's untouched optional technician card", () => { - const { result } = renderHook( - () => useVendorRosterForm({ mode: "create", startWithTechnician: true }), - { wrapper: makeWrapper(createClient()) }, - ); - - act(() => { - result.current.submit({ - name: "Independent Vendor LLC", - companyPhone: "(314) 555-0199", - email: "", - address: "", - city: "", - state: "", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - contactName: "", - phone: "", - email: "", - preferredContact: "Phone", - tradeSpecialties: "", - isActive: true, - }, - ], - }); - }); - - expect(saveMutate).toHaveBeenCalledWith( - expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }), - expect.any(Object), - ); - }); -}); - describe("useVendorRosterForm stale-selection handling", () => { beforeEach(() => { rosterGet.mockReset(); 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 df329a4a..a55ddaf0 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,11 +49,11 @@ function rosterWith(technicians: Array>) { }; } -describe("VendorDetailDrawer selected-technician display", () => { +describe("VendorDetailDrawer preferred-contact display", () => { it("renders no preference label when preferredContact is absent", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ - { id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, ]), ); @@ -62,15 +62,15 @@ describe("VendorDetailDrawer selected-technician display", () => { withAuth: false, }); - expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); + expect(screen.getByText("Adam")).toBeInTheDocument(); expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); }); - it("keeps the retired preferred-contact value out of the detail view", () => { + it("renders the preference label when an explicit preferredContact is present", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ { - id: 1, + id: 7, contactName: "Adam", phone: "314-555-0198", preferredContact: "Email", @@ -85,52 +85,6 @@ describe("VendorDetailDrawer selected-technician display", () => { withAuth: false, }); - expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); - expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); - }); - - it("falls back to the clicked row when the roster has no matching stable id", () => { - useVendorCompanyRoster.mockReturnValue( - rosterWith([ - { - id: 7, - contactName: "Adam Whyte", - phone: "999-999-9999", - isActive: true, - totalJobs: 99, - }, - ]), - ); - - renderWithProviders(, { - route: "/vendors", - withAuth: false, - }); - - expect(screen.getByText("0")).toBeInTheDocument(); - expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument(); - }); - - it("does not expose a status control for a different technician", () => { - useVendorCompanyRoster.mockReturnValue( - rosterWith([ - { - id: 7, - contactName: "Different Technician", - phone: "314-555-0101", - email: "", - tradeSpecialties: "Plumbing", - isActive: true, - totalJobs: 10, - }, - ]), - ); - - renderWithProviders( - , - { route: "/vendors", withAuth: false }, - ); - - expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument(); + expect(screen.getByText("Prefers Email")).toBeInTheDocument(); }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index a5fd7a64..64c71c69 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -1,14 +1,21 @@ -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); +const navigate = vi.fn(); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { ...actual, useNavigate: () => navigate }; +}); + vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); @@ -54,32 +61,6 @@ const inactiveVendor = { isActive: false, }; -const activeRoster: VendorCompanyRoster = { - companyId: "co-1", - rowVersion: "rv-1", - name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", - email: "dispatch@gatewayplumbing.com", - address: "", - city: "St. Louis", - state: "MO", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - id: 1, - contactName: "Adam Whyte", - phone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - preferredContact: "Email", - tradeSpecialties: "Backflow Preventers", - isActive: true, - totalJobs: 0, - }, - ], -}; - function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, @@ -90,13 +71,7 @@ function result(items: VendorListItem[], totalCount: number) { } function setupDefaults() { - useVendorCompanyRoster.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); + useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); useVendorDeactivationImpact.mockReturnValue({ data: undefined, isLoading: false, @@ -105,7 +80,7 @@ function setupDefaults() { } describe("VendorsListPage", () => { - it("renders prototype-aligned status and two row actions in both tabs", async () => { + it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { setupDefaults(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); @@ -116,20 +91,11 @@ describe("VendorsListPage", () => { expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); - expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "View vendor Gateway Plumbing" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }), - ).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" })); expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument(); - expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); @@ -148,18 +114,39 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, + data: { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + address: "", + city: "St. Louis", + state: "MO", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + preferredContact: "Email", + tradeSpecialties: "Backflow Preventers", + isActive: true, + }, + ], + }, isLoading: false, - isError: false, error: null, - refetch: vi.fn(), }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); @@ -174,18 +161,11 @@ describe("VendorsListPage", () => { row.focus(); await userEvent.keyboard("{Enter}"); - expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); }); it("blocks deactivation when the preflight reports open work orders", async () => { setupDefaults(); - useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); useVendorDeactivationImpact.mockReturnValue({ data: { vendorId: 1, @@ -209,8 +189,7 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); expect( screen.getByText(/cannot be deactivated because it still has open work orders/), @@ -220,51 +199,9 @@ describe("VendorsListPage", () => { expect(mutate).not.toHaveBeenCalled(); }); - it("preserves inline edits when deactivation is cancelled", async () => { + it("navigates to the vendor edit route when the table Edit control is clicked", async () => { setupDefaults(); - useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); - useVendorDeactivationImpact.mockReturnValue({ - data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] }, - isLoading: false, - error: null, - }); - useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { - if (params.pageSize === 1) return result([], 1); - return params.isActive ? result([activeVendor], 1) : result([], 0); - }); - - renderWithProviders(, { route: "/vendors", withAuth: false }); - - await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - const company = screen.getByRole("textbox", { name: "Company" }); - await userEvent.clear(company); - await userEvent.type(company, "Draft Company Name"); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); - await userEvent.click( - within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { - name: "Cancel", - }), - ); - - expect(company).toBeInTheDocument(); - expect(company).toHaveValue("Draft Company Name"); - }); - - it("opens inline edit when the table Edit control is clicked", async () => { - setupDefaults(); - useVendorCompanyRoster.mockReturnValue({ - data: activeRoster, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); + navigate.mockClear(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([activeVendor], 1) : result([], 0); @@ -274,20 +211,16 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); + expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); + expect( + screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), + ).not.toBeInTheDocument(); }); - it("opens inline company edit when the row has no vendor id", async () => { + it("navigates to the company route when editing a vendor without a vendor id", async () => { setupDefaults(); + navigate.mockClear(); const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; - useVendorCompanyRoster.mockReturnValue({ - data: { ...activeRoster, companyId: "co-9", technicians: [] }, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); @@ -297,7 +230,6 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); + expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); }); }); diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx new file mode 100644 index 00000000..2b3abef7 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +const baseRow: WorkOrderTableRow = { + id: 42, + woNumber: "WO-42", + site: "CHI-1042", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-20", + targetWeek: "2026-07-20", + dayGroup: "2026-07-20", + dayLabel: "Mon", + location: "Chicago Loop", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "1", + company: "Vendor Co", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 }, +}; + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ + useWorkOrderUplifts: () => ({ + data: [ + { + id: 1, + status: "pending", + amount: 450, + notes: "Extra work", + requestedAt: "2026-07-20T10:00:00Z", + requestedById: "vendor-1", + requestedByName: "Vendor", + decidedAt: "", + decidedByName: "", + decisionNote: "", + }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }), + useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), +})); + +describe("UpliftCell affordances", () => { + it("renders pending pill and opens handler when onOpen is provided", () => { + const onOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /1 pending/i })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("shows read-only dash when onOpen is omitted", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); + +describe("WorkOrderUpliftsDialog affordances", () => { + it("shows create form for editable work orders", () => { + renderWithProviders(, { + withAuth: true, + }); + + expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument(); + }); + + it("hides create and cancel actions when work order is completed", () => { + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /cancel pending/i })).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 004e96d3..4c64e8c4 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 @@ -109,6 +109,7 @@ const canceledRow: WorkOrderTableRow = { rowVersion: "", dispatchRowVersion: "", flagColor: null, + upliftSummary: { hasUplift: false, pendingCount: 0 }, }; vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ diff --git a/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts new file mode 100644 index 00000000..599ae34e --- /dev/null +++ b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), +})); + +import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; + +describe("workOrderUpliftsApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + }); + + it("propagates a real API list failure instead of returning mock uplifts", async () => { + const error = new Error("Unauthorized"); + apiGet.mockRejectedValueOnce(error); + + await expect(workOrderUpliftsApi.list(42)).rejects.toBe(error); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts new file mode 100644 index 00000000..9eb56502 --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + mapUpliftSummary, + mapWorkOrderUplift, +} from "@/domain/work-orders/mappers/work-order-uplift-mapper"; +import { + getUpliftCellLabel, + getUpliftStatusPillStyle, +} from "@/domain/work-orders/utils/uplift-display-utils"; + +describe("mapUpliftSummary", () => { + it("maps nested PascalCase upliftSummary from board row payload", () => { + const result = mapUpliftSummary({ + id: 10, + UpliftSummary: { + HasUplift: true, + PendingCount: 2, + PrimaryStatus: "Pending", + Amount: 500, + }, + }); + + expect(result).toEqual({ + hasUplift: true, + pendingCount: 2, + primaryStatus: "pending", + amount: 500, + }); + }); + + it("derives hasUplift from pendingUpliftCount when nested summary is absent", () => { + const result = mapUpliftSummary({ + pendingUpliftCount: 1, + }); + + expect(result.hasUplift).toBe(true); + expect(result.pendingCount).toBe(1); + }); + + it("normalizes auto-approved and canceled status aliases", () => { + expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved"); + expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled"); + }); + + it("maps requester identity for revoke ownership checks", () => { + expect(mapWorkOrderUplift({ requestedById: "dispatcher-1" }).requestedById).toBe( + "dispatcher-1", + ); + }); +}); + +describe("uplift display utils", () => { + it("builds pending pill label and style", () => { + const summary = { hasUplift: true, pendingCount: 1, primaryStatus: "pending" as const }; + expect(getUpliftCellLabel(summary)).toBe("1 Pending"); + expect(getUpliftStatusPillStyle("pending")).toEqual({ + background: "var(--status-pending-bg)", + color: "var(--status-pending-text)", + }); + }); + + it("includes amount in non-pending primary status label", () => { + expect( + getUpliftCellLabel({ + hasUplift: true, + pendingCount: 0, + primaryStatus: "approved", + amount: 1200, + }), + ).toBe("Approved $1,200"); + }); +}); diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 25788b12..257a8503 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -4,6 +4,7 @@ import { isSlideOverDraftDirty, SLIDE_OVER_EDIT_KEYS, } from "@/domain/work-orders/utils/slide-over-draft"; +import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; function baseRow(overrides: Partial = {}): WorkOrderTableRow { @@ -49,6 +50,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "1", dispatchRowVersion: "", flagColor: null, + upliftSummary: EMPTY_UPLIFT_SUMMARY, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 4667ee78..3fa7af62 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; import { @@ -53,6 +54,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow rowVersion: "AQAAAAAAAAA=", dispatchRowVersion: "", flagColor: null, + upliftSummary: EMPTY_UPLIFT_SUMMARY, ...overrides, }; } diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts new file mode 100644 index 00000000..c441ec4f --- /dev/null +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + canRevokeWorkOrderUplift, + upliftRevokeRequiresReason, +} from "@/domain/work-orders/utils/uplift-display-utils"; + +describe("canRevokeWorkOrderUplift", () => { + it("allows a dispatcher to revoke their own auto-approved uplift", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "dispatcher-1" }, + "dispatcher-1", + false, + ), + ).toBe(true); + }); + + it("rejects a dispatcher revoking another user's auto-approved uplift", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "other-dispatcher" }, + "dispatcher-1", + false, + ), + ).toBe(false); + }); + + it("rejects admin revoking auto-approved uplift they did not request", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "dispatcher-1" }, + "admin-user", + true, + ), + ).toBe(false); + }); + + it("allows admin to revoke their own auto-approved uplift as the requester", () => { + expect( + canRevokeWorkOrderUplift( + { status: "auto_approved", requestedById: "admin-user" }, + "admin-user", + true, + ), + ).toBe(true); + }); + + it("allows approved revoke only for admin", () => { + const uplift = { status: "approved" as const, requestedById: "dispatcher-1" }; + expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", false)).toBe(false); + expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", true)).toBe(true); + }); + + it("rejects revoke for other statuses", () => { + expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1", true)).toBe( + false, + ); + expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1", true)).toBe( + false, + ); + }); +}); + +describe("upliftRevokeRequiresReason", () => { + it("requires reason only for approved uplifts", () => { + expect(upliftRevokeRequiresReason("approved")).toBe(true); + expect(upliftRevokeRequiresReason("auto_approved")).toBe(false); + }); +}); diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts new file mode 100644 index 00000000..eddb88c5 --- /dev/null +++ b/src/test/lib/auth/user-utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isAdminUser } from "@/lib/auth/user-utils"; + +describe("isAdminUser", () => { + it("returns true when Admin appears in roles", () => { + expect(isAdminUser("Admin")).toBe(true); + expect(isAdminUser("Dispatcher,Admin")).toBe(true); + expect(isAdminUser(" admin ")).toBe(true); + }); + + it("returns false for non-admin roles", () => { + expect(isAdminUser("Dispatcher")).toBe(false); + expect(isAdminUser(undefined)).toBe(false); + }); +}); diff --git a/tmp/pr-descriptions/pr3-uplifts-column-tab.md b/tmp/pr-descriptions/pr3-uplifts-column-tab.md new file mode 100644 index 00000000..d0fa7a36 --- /dev/null +++ b/tmp/pr-descriptions/pr3-uplifts-column-tab.md @@ -0,0 +1,46 @@ +## Jira + +- [SH-196](https://luby-us.atlassian.net/browse/SH-196) + +## QA residual + +- Conscious adaptation: **dialog** vs design slide-over tab (no slide-over on this `dev`). +- P0 done: admin revoke of `approved` with required reason. +- P1: `+ Add` / Manage + attachments polish. + +## Summary + +Adds the **UPLIFT** column to the work orders board table (after COMP DOC) with a status pill that opens a **uplifts dialog** (not a slide-over tab — board currently navigates to `/workorders/:id` on row detail). + +- Maps `upliftSummary` on each board row: `{ hasUplift, pendingCount, primaryStatus?, amount? }` +- Dialog: list uplifts, create (amount/notes + attachments UI stub), cancel pending, revoke `auto_approved` (optional reason) and `approved` for **admin** (required reason, SH-196); read-only when status is Completed/Canceled +- WO-scoped API client + query keys under `workorders/{id}/uplifts` +- DEV in-memory mock fallback when API calls fail + +## API / assumptions + +| Item | Assumption | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| UI surface | **Dialog** from UPLIFT column click — design ref uses slide-over tab, but `dev` has no slide-over; detail opens via route navigation | +| Row field | `upliftSummary` on GET `/board` and `/board/search` rows (see `docs/work-orders/pending-be-contract.md`) | +| Endpoints | `GET/POST workorders/{id}/uplifts`, `POST …/uplifts/{id}/cancel\|revoke` — distinct from NTE queue `uplifts` API | +| Attachments | Create form shows disabled attachments field until BE accepts files | +| DEV mock | When `import.meta.env.DEV` and API throws, `work-order-uplifts-mock-store` serves list/create/cancel/revoke | + +## Out of scope (later PRs) + +- Closability / Comp Doc pending gate (PR4) +- Advanced filter uplift facets (PR5) +- Vendor Portal NTE queue + +## Test plan + +- [ ] Board row shows UPLIFT pill after COMP DOC (pending count or primary status + amount) +- [ ] Click UPLIFT opens dialog; Completed/Canceled rows are read-only +- [ ] Create uplift (DEV mock) updates list; cancel pending / revoke auto_approved work +- [ ] Admin can revoke `approved` uplifts with required reason; non-admin cannot +- [ ] `npm run verify` green + +## Design ref + +`seahaven-desing` — `WorkOrders.tsx` `UpliftCell` (~L4669) From 2ed770bc79f2c838b23e9ffff5bf03057f9a1de2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:13 -0300 Subject: [PATCH 02/31] fix(work-orders): separate Unscheduled header collapse and Add WO controls [recover] remove malicious eslint payload (was ccec7322) --- docs/work-orders/board-search-api.md | 9 + .../list/table/wo-table-section-header.tsx | 74 +++++--- .../work-orders/utils/table-filter-utils.ts | 13 +- .../wo-table-body-unscheduled.test.tsx | 153 ++++++++++++++++ .../board-unscheduled-design-parity.test.ts | 165 ++++++++++++++++++ .../utils/table-filter-utils.test.ts | 65 +++++++ 6 files changed, 445 insertions(+), 34 deletions(-) create mode 100644 src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx create mode 100644 src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index fde9438b..e476f53c 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -118,6 +118,15 @@ Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3. } ``` +#### Buckets + +| Array | Inclusion rule | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `scheduled` | WOs with `scheduledDate` inside `weekStart`–`weekEnd` | +| `unscheduled` | **All** WOs without `scheduledDate` (cross-week). Visible for every week request; `targetWeek` is not a week filter — Schedule-cell marker only | + +The FE concatenates `unscheduled + scheduled` into `items` and renders `unscheduled` in the pinned Unscheduled section above day groups. Dated WOs without a dispatcher stay in `scheduled` / day groups (Assigned To highlight), not in `unscheduled`. + ### `/board/search` ```json diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx index 6b22e968..6f281829 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -23,52 +23,79 @@ export function WoTableSectionHeader({ const isUnscheduled = variant === "unscheduled"; if (isUnscheduled) { + const toggleCollapse = () => { + onToggleCollapse?.(); + }; + return (
    @@ -129,10 +156,7 @@ export function WoTableSectionHeader({ type="button" className="flex items-center gap-1 transition-opacity hover:opacity-70" style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }} - onClick={(e) => { - e.stopPropagation(); - onAddWo(); - }} + onClick={onAddWo} > Add WO diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 4ea3c92f..be0b6ded 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -6,7 +6,6 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates"; -import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]); @@ -91,14 +90,10 @@ export function filterByBarAndSearch( } } - if (row.scheduledOn) { - if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) { - return false; - } - } else if (row.targetWeek) { - if (!isDateInWeek(row.targetWeek, bar.weekMonday)) { - return false; - } + // Design weekScoped: undated (!scheduledOn) stay visible every week; targetWeek + // is a Schedule-cell marker only, never a week-inclusion predicate. + if (row.scheduledOn && (row.scheduledOn < weekFrom || row.scheduledOn > weekTo)) { + return false; } return true; diff --git a/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx b/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx new file mode 100644 index 00000000..82551058 --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx @@ -0,0 +1,153 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body"; +import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "24991", + site: "CLV2", + type: "PM", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-06-02", + targetWeek: "", + dayGroup: "06/02", + dayLabel: "TUE · 06/02", + location: "Cleveland, OH", + locationId: "", + pm: "Backflow", + serviceNotes: "", + extraServices: [], + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "—", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + vendorId: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +function baseTableData( + overrides: Partial = {}, +): ReturnTypeOfUseWorkOrdersTableData { + return { + rows: [], + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + advActive: false, + advResults: [], + unscheduled: [], + displayGroups: [], + searchActive: false, + noSearchResults: false, + isEmptyWeek: false, + todayKey: null, + draftDayKey: null, + draft: null, + setDraft: vi.fn(), + unschedOpen: true, + setUnschedOpen: vi.fn(), + selectedId: null, + setSelectedId: vi.fn(), + selectedRow: null, + flashId: null, + triggerFlash: vi.fn(), + dragViz: null, + setDragViz: vi.fn(), + dragState: null, + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + startDayDraft: vi.fn(), + cancelDraft: vi.fn(), + addRow: vi.fn(), + scopeTotalCount: 0, + totalCount: 0, + search: "", + ...overrides, + } as ReturnTypeOfUseWorkOrdersTableData; +} + +describe("WoTableBody Unscheduled vs day groups", () => { + it("renders Unscheduled above day groups and keeps dated unassigned under the day header", () => { + const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" }); + const undated = makeRow({ + id: "us1", + woNumber: "25021", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + + const tableData = baseTableData({ + unscheduled: [undated], + displayGroups: [ + { + key: "06/02", + label: "TUE · 06/02", + date: "2026-06-02", + items: [datedUnassigned], + }, + ], + unschedOpen: true, + }); + + render( +
    — + — + e.stopPropagation()}> + handlers.onOpenUplifts!(row) + : undefined + } + /> +
    - - - {label} - - - {count} work order{count !== 1 ? "s" : ""} - + + + {label} + + + {count} work order{count !== 1 ? "s" : ""} + + - +
    + ( + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber}
    , + ); + + expect(screen.getByRole("button", { name: "Unscheduled" })).toBeInTheDocument(); + expect(screen.getByText("TUE · 06/02")).toBeInTheDocument(); + + const unscheduledHeader = screen.getByRole("button", { name: "Unscheduled" }); + const dayLabel = screen.getByText("TUE · 06/02"); + const undatedRow = screen.getByTestId("row-us1"); + const datedRow = screen.getByTestId("row-u-dated"); + + expect( + unscheduledHeader.compareDocumentPosition(undatedRow) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + dayLabel.compareDocumentPosition(datedRow) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.queryByTestId("row-u-dated")).toBeInTheDocument(); + expect(screen.getAllByTestId(/row-/)).toHaveLength(2); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts b/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts new file mode 100644 index 00000000..4c919a28 --- /dev/null +++ b/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { + filterByBarAndSearch, + splitScheduledUnscheduled, +} from "@/domain/work-orders/utils/table-filter-utils"; +import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; + +/** + * Design oracle — mirrors shoc-desing/src/pages/WorkOrders.tsx: + * - weekScoped (~L9734): !scheduledOn || (scheduledOn >= wStart && scheduledOn <= wEnd) + * - split (~L9762): unscheduled = !scheduledOn; dated-but-unassigned stay in day groups + */ + +type DesignRow = Pick; + +function designWeekScoped(rows: DesignRow[], weekStart: string, weekEnd: string): DesignRow[] { + return rows.filter( + (row) => !row.scheduledOn || (row.scheduledOn >= weekStart && row.scheduledOn <= weekEnd), + ); +} + +function designSplit(rows: DesignRow[]): { + scheduled: DesignRow[]; + unscheduled: DesignRow[]; +} { + const scheduled: DesignRow[] = []; + const unscheduled: DesignRow[] = []; + for (const row of rows) { + if (!row.scheduledOn) { + unscheduled.push(row); + } else { + scheduled.push(row); + } + } + return { scheduled, unscheduled }; +} + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "24991", + site: "CLV2", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Alice", + scheduledOn: "2026-06-02", + targetWeek: "", + dayGroup: "06/02", + dayLabel: "TUE · 06/02", + location: "Cleveland, OH", + locationId: "", + pm: "Backflow", + serviceNotes: "", + extraServices: [], + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "—", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + vendorId: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +function idsOf(rows: { id: string | number }[]): string[] { + return rows.map((row) => String(row.id)).sort(); +} + +describe("board Unscheduled design parity", () => { + const weekMonday = "2026-06-01"; + const [weekStart, weekEnd] = weekRange(weekMonday); + + const fixtures: WorkOrderTableRow[] = [ + makeRow({ + id: "u-dated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-06-02", + ...deriveDayFields("2026-06-02"), + }), + makeRow({ + id: "us-undated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }), + makeRow({ + id: "ua-assigned-undated", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }), + makeRow({ + id: "tw-other-week", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "", + targetWeek: "2026-07-06", + dayGroup: "", + dayLabel: "", + }), + makeRow({ + id: "dated-other-week", + scheduledOn: "2026-06-15", + ...deriveDayFields("2026-06-15"), + }), + ]; + + const bar: WorkOrderBarFilters = { + weekMonday, + dispatcherIds: new Set(), + type: "All", + }; + + it("matches design weekScoped ID set via filterByBarAndSearch", () => { + const designIds = idsOf(designWeekScoped(fixtures, weekStart, weekEnd)); + const feIds = idsOf(filterByBarAndSearch(fixtures, bar, "")); + expect(feIds).toEqual(designIds); + expect(feIds).toContain("tw-other-week"); + expect(feIds).not.toContain("dated-other-week"); + }); + + it("matches design split: dated unassigned in scheduled, undated in Unscheduled", () => { + const scoped = filterByBarAndSearch(fixtures, bar, ""); + const feSplit = splitScheduledUnscheduled(scoped); + const designSplitResult = designSplit(designWeekScoped(fixtures, weekStart, weekEnd)); + + expect(idsOf(feSplit.unscheduled)).toEqual(idsOf(designSplitResult.unscheduled)); + expect(idsOf(feSplit.scheduled)).toEqual(idsOf(designSplitResult.scheduled)); + + expect(idsOf(feSplit.scheduled)).toContain("u-dated"); + expect(idsOf(feSplit.unscheduled)).not.toContain("u-dated"); + expect(idsOf(feSplit.unscheduled)).toContain("tw-other-week"); + expect(idsOf(feSplit.unscheduled)).toEqual( + expect.arrayContaining(["us-undated", "ua-assigned-undated", "tw-other-week"]), + ); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 4667ee78..b059bbd3 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { buildDayGroups, + filterByBarAndSearch, matchesAdvancedFilters, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -57,6 +59,18 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow }; } +function bar( + weekMonday: string, + overrides: Partial = {}, +): WorkOrderBarFilters { + return { + weekMonday, + dispatcherIds: new Set(), + type: "All", + ...overrides, + }; +} + describe("deriveDayFields", () => { it("derives MM/DD group and weekday label", () => { expect(deriveDayFields("2026-06-23")).toEqual({ @@ -66,6 +80,40 @@ describe("deriveDayFields", () => { }); }); +describe("filterByBarAndSearch", () => { + const weekMonday = "2026-06-01"; + + it("keeps undated rows for any week, including targetWeek outside the active week", () => { + const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" }); + const undatedOtherTarget = makeRow({ + id: "tw1", + scheduledOn: "", + targetWeek: "2026-07-06", + }); + + const ids = filterByBarAndSearch([undated, undatedOtherTarget], bar(weekMonday), "").map( + (row) => row.id, + ); + expect(ids).toEqual(["us1", "tw1"]); + }); + + it("excludes dated rows outside the active week", () => { + const inWeek = makeRow({ + id: "in", + scheduledOn: "2026-06-03", + ...deriveDayFields("2026-06-03"), + }); + const outWeek = makeRow({ + id: "out", + scheduledOn: "2026-06-15", + ...deriveDayFields("2026-06-15"), + }); + + const ids = filterByBarAndSearch([inWeek, outWeek], bar(weekMonday), "").map((row) => row.id); + expect(ids).toEqual(["in"]); + }); +}); + describe("splitScheduledUnscheduled", () => { it("splits rows without scheduledOn into unscheduled", () => { const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" }); @@ -75,6 +123,23 @@ describe("splitScheduledUnscheduled", () => { expect(result.unscheduled).toHaveLength(1); expect(result.unscheduled[0].id).toBe("b"); }); + + it("keeps dated unassigned in scheduled and undated in unscheduled without duplicate ids", () => { + const datedUnassigned = makeRow({ + id: "u-dated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-06-02", + ...deriveDayFields("2026-06-02"), + }); + const undated = makeRow({ id: "us-undated", scheduledOn: "", dispatcherId: "" }); + const result = splitScheduledUnscheduled([datedUnassigned, undated]); + + expect(result.scheduled.map((row) => row.id)).toEqual(["u-dated"]); + expect(result.unscheduled.map((row) => row.id)).toEqual(["us-undated"]); + const allIds = [...result.scheduled, ...result.unscheduled].map((row) => row.id); + expect(new Set(allIds).size).toBe(allIds.length); + }); }); describe("buildDayGroups", () => { From f4cfafd7a611a22ffcdc35610f2d4ce27d3f5460 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 10 Aug 2026 19:07:11 -0300 Subject: [PATCH 03/31] fix(work-orders): single Unscheduled disclosure control for a11y Co-authored-by: Cursor --- .../list/table/wo-table-section-header.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx index 6f281829..a27feca7 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -42,9 +42,9 @@ export function WoTableSectionHeader({
    -
    From d5fd6aa5d68a66f257cde2964184d3dec7d5cf13 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 10 Aug 2026 19:09:25 -0300 Subject: [PATCH 04/31] fix(work-orders): stop uplift column reading WO status; strip blank techs mapUpliftSummary no longer treats row status as uplift primaryStatus, getUpliftCellLabel hides pills without hasUplift, and vendor submit filters blank new technicians. --- .../_components/use-vendor-roster-form.ts | 20 ++++++++++++- .../mappers/work-order-uplift-mapper.ts | 12 ++++++-- .../work-orders/utils/uplift-display-utils.ts | 3 ++ .../mappers/work-order-uplift-mapper.test.ts | 29 +++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) 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 f6ea637a..b4c3a51f 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -20,6 +20,24 @@ import type { VendorRosterConflict, } from "@/domain/vendors/types/vendor"; +function withoutBlankNewTechnicians( + values: VendorCompanyRosterFormValues, +): VendorCompanyRosterFormValues { + return { + ...values, + technicians: values.technicians.filter( + (technician) => + technician.id != null || + Boolean( + technician.contactName.trim() || + technician.phone.trim() || + technician.email.trim() || + technician.tradeSpecialties.trim(), + ), + ), + }; +} + export interface VendorRosterFormProps { mode: "create" | "update"; vendorId?: string | number; @@ -109,7 +127,7 @@ export function useVendorRosterForm({ save.mutate( { mode: isUpdate ? "update" : "create", - values: formValues, + values: withoutBlankNewTechnicians(formValues), companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts index a4e9f32f..ec712467 100644 --- a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts @@ -88,7 +88,8 @@ function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus { export function mapUpliftSummary(raw: unknown): UpliftSummary { const item = asRecord(raw); const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); - const source = Object.keys(nested).length > 0 ? nested : item; + const hasNested = Object.keys(nested).length > 0; + const source = hasNested ? nested : item; const pendingCount = readNumber(source, "pendingCount", "PendingCount") ?? @@ -99,8 +100,13 @@ export function mapUpliftSummary(raw: unknown): UpliftSummary { readBool(item, "hasUplift", "HasUplift") || pendingCount > 0; - const primaryStatusRaw = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); - const amount = readNumber(source, "amount", "Amount"); + // Never treat the work-order lifecycle status/Status as uplift primaryStatus. + const primaryStatusRaw = hasNested + ? readString(source, "primaryStatus", "PrimaryStatus", "status", "Status") + : readString(item, "primaryStatus", "PrimaryStatus"); + const amount = hasNested + ? readNumber(source, "amount", "Amount") + : readNumber(item, "amount", "Amount"); const summary: UpliftSummary = { hasUplift, diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts index 431f74c1..49c9da15 100644 --- a/src/domain/work-orders/utils/uplift-display-utils.ts +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -30,6 +30,9 @@ export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string { } export function getUpliftCellLabel(summary: UpliftSummary): string | null { + if (!summary.hasUplift && summary.pendingCount <= 0) { + return null; + } if (summary.pendingCount > 0) { return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`; } diff --git a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts index 9eb56502..d765c4d5 100644 --- a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts @@ -37,6 +37,18 @@ describe("mapUpliftSummary", () => { expect(result.pendingCount).toBe(1); }); + it("does not treat work-order status as uplift primaryStatus when nested summary is absent", () => { + const result = mapUpliftSummary({ + status: "Cancelled", + Status: "Cancelled", + hasUplift: false, + }); + + expect(result.hasUplift).toBe(false); + expect(result.primaryStatus).toBeUndefined(); + expect(getUpliftCellLabel(result)).toBeNull(); + }); + it("normalizes auto-approved and canceled status aliases", () => { expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved"); expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled"); @@ -69,4 +81,21 @@ describe("uplift display utils", () => { }), ).toBe("Approved $1,200"); }); + + it("hides Pending/Cancelled when hasUplift is false", () => { + expect( + getUpliftCellLabel({ + hasUplift: false, + pendingCount: 0, + primaryStatus: "pending", + }), + ).toBeNull(); + expect( + getUpliftCellLabel({ + hasUplift: false, + pendingCount: 0, + primaryStatus: "cancelled", + }), + ).toBeNull(); + }); }); From cd5e16465785747f9b6865b397c51a254dfd72f8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 11 Aug 2026 15:25:46 -0300 Subject: [PATCH 05/31] fix(work-orders): drop vendor collateral from SH-196 slice Restore vendors, e2e, and page-header to origin/dev so this PR stays an uplift-only vertical slice and clears the VendorFiltersDrawer maintainability gate. --- e2e/vendors/vendors.spec.ts | 147 ++++-- .../_components/use-vendor-deactivation.ts | 7 +- .../_components/use-vendor-list-state.ts | 2 +- .../_components/use-vendor-roster-form.ts | 16 +- .../_components/vendor-create-modal.tsx | 33 +- .../_components/vendor-detail-drawer.tsx | 418 ++++++++++++------ .../_components/vendor-filter-options.tsx | 252 +++++++++++ .../_components/vendor-filters-drawer.tsx | 239 +--------- .../_components/vendor-roster-form-fields.tsx | 101 +++-- .../_components/vendors-list-toolbar.tsx | 147 +++--- .../vendors/_components/vendors-table.tsx | 242 ++++++---- src/app/(protected)/vendors/index.tsx | 125 +++++- src/components/ui/page-header.tsx | 25 +- .../vendors/use-vendor-roster-form.test.tsx | 45 +- .../vendors/vendor-detail-drawer.test.tsx | 58 ++- .../(protected)/vendors/vendors-list.test.tsx | 166 +++++-- tmp/pr-descriptions/pr3-uplifts-column-tab.md | 46 -- 17 files changed, 1321 insertions(+), 748 deletions(-) create mode 100644 src/app/(protected)/vendors/_components/vendor-filter-options.tsx delete mode 100644 tmp/pr-descriptions/pr3-uplifts-column-tab.md diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 8bbfdfb8..ad1a89e8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); - await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); - await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); + const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("font-family", /Montserrat/); + await expect(heading).toHaveCSS("font-size", "30px"); + await expect(heading).toHaveCSS("font-weight", "800"); + await expect(heading).toHaveCSS("line-height", "33px"); + await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); + await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", - "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); + const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); + expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); + const tableContainerBox = await page.locator("table").locator("..").boundingBox(); + expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); + expect(tableContainerBox?.height).toBeCloseTo(738, 0); + expect((await page.locator("table").boundingBox())?.width).toBe(1550); + expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( + 47, + ); + expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active technicians")).toBeVisible(); + await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); - await page.getByPlaceholder("Search name, company, trade...").fill("metro"); + await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); - await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); + await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await page.getByPlaceholder("Search name, company, trade...").clear(); + await page.getByPlaceholder("Search name, company, trade…").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); - await page.getByRole("button", { name: "Apply" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText("1 technician · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); - await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( - "history will be preserved", - ); - await page.getByRole("button", { name: "Deactivate", exact: true }).click(); - await expect.poll(() => state.deletedId).toBe("1"); - await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); + await expect(page.getByText("No inactive technicians found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "+ Add Vendor" }).click(); - await expect( - page.getByText("Register a company and add zero, one, or multiple technicians."), - ).toBeVisible(); + await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); + await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); + await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( + "https://maps.google.com/gateway", + ); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician Name").last().fill("New Technician"); + await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); - await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); + await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByLabel("Notes").fill("Updated in browser E2E"); - await page.getByRole("button", { name: "Save" }).click(); + await expect(page).toHaveURL(/\/vendors$/); + await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); - await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByRole("switch", { name: "Inactive" }).first().click(); - await page.getByRole("button", { name: "Save" }).click(); + await detailDrawer.getByRole("switch", { name: "Active status" }).click(); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); + await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "+ Add Vendor" }); + const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await page.getByLabel("Company Phone (optional)").fill("3145550199"); + 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([]); @@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -537,10 +556,25 @@ test.describe("Vendor directory prototype parity", () => { await mockVendorApi(page, { listError: true }); await page.goto("/vendors"); + const mainBox = await page.getByRole("main").boundingBox(); + expect(mainBox).toMatchObject({ x: 0, width: 390 }); + const navigation = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(navigation).not.toBeVisible(); + + const openNavigation = page.getByRole("button", { name: "Toggle navigation" }); + await openNavigation.focus(); + await page.keyboard.press("Enter"); + await expect(navigation).toBeVisible(); + await expect(page.getByRole("link", { name: "Sites" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(navigation).not.toBeVisible(); + await expect(openNavigation).toBeFocused(); + await expect(page.getByRole("main").getByRole("alert")).toContainText( /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, ); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png"); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); @@ -549,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); await page.getByRole("button", { name: "Close filters" }).click(); }); + + test("keeps protected shell destinations available and marks their selected state", async ({ + page, + }) => { + await mockVendorApi(page); + + for (const destination of [ + { path: "/workorders", label: "Work Orders" }, + { path: "/accounts", label: "Accounts" }, + { path: "/locations", label: "Sites" }, + { path: "/notifications", label: "Notifications" }, + ]) { + await page.goto(destination.path); + await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute( + "aria-current", + "page", + ); + } + }); }); diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index 9b6f3b0d..b63291be 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,7 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(): VendorDeactivationState { +export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -39,7 +39,10 @@ export function useVendorDeactivation(): VendorDeactivationState { if (!target || target.id == null) return; setError(null); deleteVendor.mutate(target.id, { - onSuccess: () => setTarget(null), + onSuccess: () => { + setTarget(null); + onSuccess?.(); + }, onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); }; 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 9385a834..fdc90685 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); + const [pageSize, setPageSize] = useState(48); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); 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 b4c3a51f..6bf89b1f 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, + emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -43,6 +44,7 @@ export interface VendorRosterFormProps { vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; + startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -73,6 +75,7 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, + startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -87,9 +90,16 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); + const createDefaults = useMemo( + () => ({ + ...emptyVendorCompanyRosterForm, + technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], + }), + [startWithTechnician], + ); const form = useForm({ resolver, - defaultValues: emptyVendorCompanyRosterForm, + defaultValues: createDefaults, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -114,10 +124,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(emptyVendorCompanyRosterForm); + reset(createDefaults); resetSelection(); clearConflict(); - }, [clearConflict, reset, resetSelection]); + }, [clearConflict, createDefaults, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index d0025662..31804a3c 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,7 +21,11 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const form = useVendorRosterForm({ + mode: "create", + onSuccess: onClose, + startWithTechnician: true, + }); const { resetForm } = form; useEffect(() => { @@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="md" + maxWidth="sm" aria-labelledby="vendor-create-dialog-title" - slotProps={{ paper: { role: "dialog" } }} + slotProps={{ + paper: { + role: "dialog", + sx: { maxHeight: "88vh", borderRadius: 2 }, + }, + }} > - Add Vendor Company - - Register a company and add zero, one, or multiple technicians. - + + Add Vendor + + + Register a vendor company and its technicians. + - + {form.loadErrorMessage !== null && ( @@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving || !form.isValid} + disabled={form.isSaving} > - {form.isSaving ? : "Add Vendor Company"} + {form.isSaving ? : "Add Vendor"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index ad7ffa29..74d6ef02 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useEffect, useState, type ReactNode } from "react"; +import { Controller, useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,15 +13,22 @@ import { Drawer, IconButton, Link, - List, - ListItem, Stack, - Typography, + Switch, } from "@mui/material"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; +import { Text } from "@/components/ui/text"; +import type { + VendorCompanyRoster, + VendorListItem, + VendorRosterTechnician, +} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 440; +const DRAWER_WIDTH = 420; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; + startInEdit?: boolean; + onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; +function Field({ label, value }: { label: string; value?: string }) { + const display = !value || value.trim() === "" ? "—" : value; return ( - + {label} - - {display} + + {display} ); } -function TechnicianEntry({ - contactName, - phone, - email, - preferredContact, - tradeSpecialties, - isActive, - totalJobs, -}: { - contactName: string; - phone: string; - email: string; - preferredContact?: string; - tradeSpecialties: string; - isActive: boolean; - totalJobs: number; -}) { - const trades = splitTrades(tradeSpecialties); - return ( - - - - - {contactName || "—"} - - - - {totalJobs} jobs - - - - {Boolean(phone) && ( - - {phone} - - )} - {Boolean(email) && ( - - {email} - - )} - - - {Boolean(preferredContact) && ( - - Prefers {preferredContact} - - )} - {trades.map((trade, index) => ( - - ))} - - - - ); -} - function DrawerHeader({ roster, vendor, + technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; + technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -141,23 +87,18 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 3, + px: 2.5, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {roster?.name || vendor?.companyName || "Vendor company"} - - - {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} - + + {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} + + + {roster?.name || vendor?.companyName || "Vendor details"} + @@ -167,18 +108,19 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const mapsUrl = roster.googleMapsUrl; + const address = [roster.address, roster.city, roster.state, roster.zip] + .filter(Boolean) + .join(", "); + return ( + - - - {Boolean(mapsUrl) && ( + + + {Boolean(roster.googleMapsUrl) && ( - Technicians - {roster.technicians.length === 0 ? ( - - No technicians assigned to this company. - - ) : ( - - {roster.technicians.map((technician) => ( - + + + + Trade + + + {trades.length > 0 ? ( + trades.map((trade, index) => ( + + )) + ) : ( + — + )} + + + + + {technician.totalJobs ?? 0} + + Total Jobs + + + + + Status + + - ))} - - )} +
    +
    +
    ); } -function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { +function DrawerBody({ + roster, + technician, +}: { + roster: VendorCompanyRoster; + technician?: VendorRosterTechnician; +}) { return ( - - Company - + + + Company + + - + {technician ? ( + + ) : ( + + No technicians assigned to this company. + + )} {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { - const navigate = useNavigate(); +function DrawerEditor({ + vendor, + onClose, + onSaved, + onRequestDeactivation = () => undefined, +}: { + vendor: VendorListItem; + onClose: () => void; + onSaved: () => void; + onRequestDeactivation: (vendor: VendorListItem) => void; +}) { + const form = useVendorRosterForm({ + mode: "update", + vendorId: vendor.id ?? undefined, + companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, + onSuccess: onSaved, + }); + const roster = form.roster; + const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; + const selectedIndex = technicians.findIndex( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + ); + const selectedTotalJobs = + roster?.technicians.find( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + )?.totalJobs ?? vendor.totalJobs; + + if (form.isLoading) { + return ; + } + if (form.isError || !roster) { + return ( + + Failed to load vendor company. Try again. + + ); + } + + return ( + + + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( + + )} + + {selectedIndex >= 0 && ( + <> + + + + {selectedTotalJobs} + + Total Jobs + + + ( + + {field.value ? "Active" : "Inactive"} + { + if (field.value && !checked) onRequestDeactivation(vendor); + else field.onChange(checked); + }} + /> + + )} + /> + + + )} + + + + + + + ); +} + +function resolveTechnician( + roster: VendorCompanyRoster | undefined, + vendor: VendorListItem | null, +): VendorRosterTechnician | undefined { + if (!roster || !vendor) return undefined; + + const match = roster.technicians.find( + (technician) => + vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), + ); + if (match) return match; + if (!vendor.contactName) return undefined; + + return { + id: vendor.id ?? undefined, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + tradeSpecialties: vendor.tradeSpecialties, + isActive: vendor.isActive, + totalJobs: vendor.totalJobs, + }; +} + +export function VendorDetailDrawer({ + open, + vendor, + startInEdit = false, + onRequestDeactivation = () => undefined, + onClose, +}: VendorDetailDrawerProps) { + const [isEditing, setIsEditing] = useState(startInEdit); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer !hasVendorId ? (companyId ?? undefined) : undefined, ); - const editHref = hasVendorId - ? `/vendors/edit/${vendorId}` - : `/vendors/company/${companyId ?? ""}`; + const selectedTechnician = resolveTechnician(roster, vendor); + + useEffect(() => { + if (open) setIsEditing(startInEdit); + }, [open, startInEdit, vendor]); useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); + if (event.key === "Escape" && !isEditing) onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [onClose, open]); + }, [isEditing, onClose, open]); return ( { + if (reason === "escapeKeyDown" && isEditing) return; + onClose(); + }} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer }} > - + - {isLoading ? ( + {isEditing && vendor ? ( + setIsEditing(false)} + onRequestDeactivation={onRequestDeactivation} + /> + ) : isLoading ? ( @@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer ) : roster ? ( <> - - navigate(editHref)} /> + + setIsEditing(true)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx new file mode 100644 index 00000000..c9be7d45 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -0,0 +1,252 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +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 TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; + +type DraftSetter = Dispatch>; + +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + +function CompanyFilter({ + options, + draft, + setDraft, +}: { + options: VendorFacetCompany[]; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + const allSelected = draft.companies.length === options.length && options.length > 0; + return ( + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={options.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +interface FilterProps { + options: T[]; + draft: VendorFilters; + setDraft: DraftSetter; +} + +function LocationFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={options.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobCountFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={options.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFilterOptions({ + facets, + draft, + setDraft, +}: { + facets: VendorFacets | undefined; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index ae4b51ba..e3b91cf5 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,32 +1,9 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; -import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import { useEffect, useState } from "react"; import CloseIcon from "@mui/icons-material/Close"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; -import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - Checkbox, - Chip, - Drawer, - IconButton, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, -} from "@/domain/vendors/types/vendor"; +import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import { VendorFilterOptions } from "./vendor-filter-options"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps { const DRAWER_WIDTH = 360; -function FilterSection({ - title, - icon, - children, -}: { - title: string; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - export function VendorFiltersDrawer({ open, onClose, @@ -74,23 +25,12 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) { - setDraft(appliedFilters); - } + if (open) setDraft(appliedFilters); }, [open, appliedFilters]); - const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); - const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); - const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); - const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); - const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; - const handleClear = () => { - setDraft(emptyVendorFilters); - }; - const handleApply = () => { onApply(draft); onClose(); @@ -133,165 +73,7 @@ export function VendorFiltersDrawer({
    - }> - - - Select vendor companies - - - - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={companyOptions.filter((option) => draft.companies.includes(option.name))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetCompany)[]).map((entry) => - typeof entry === "string" ? entry : entry.name, - ); - setDraft((current) => ({ ...current, companies: next })); - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.label === value.label - } - value={locationOptions.filter((option) => draft.locations.includes(option.label))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetLocation)[]).map((entry) => - typeof entry === "string" ? entry : entry.label, - ); - setDraft((current) => ({ ...current, locations: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.id === value.id - } - value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => - typeof entry === "string" ? entry : entry.id, - ); - setDraft((current) => ({ ...current, jobBuckets: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - +
    `1px solid ${theme.palette.divider}`, }} > -
    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 32f91675..d6d5cbf7 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -28,6 +28,7 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; + showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -54,6 +55,7 @@ function CompanyNameField({ )} @@ -119,10 +122,7 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - - Company - - + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message ?? "Phone or email required"} + helperText={errors.companyPhone?.message} fullWidth /> )} @@ -155,7 +155,8 @@ function CompanyFields({ render={({ field }) => ( } + render={({ field }) => ( + + )} /> - + ( )} @@ -206,7 +214,14 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -221,6 +236,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; + showStatus: boolean; } function TechnicianRow({ @@ -230,12 +246,13 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, + showStatus, }: TechnicianRowProps) { return ( - Technician {index + 1} + {index + 1} - TECHNICIAN @@ -256,7 +273,8 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -292,26 +310,29 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth + sx={{ display: "none" }} /> )} /> - ( - field.onChange(checked)} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> + {showStatus && ( + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + )} ); } @@ -320,10 +341,12 @@ function TechniciansFieldArray({ control, errors, tradeOptions, + showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; + showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -366,6 +389,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} + showStatus={showStatus} /> )) )} @@ -375,12 +399,17 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [] } = props; + const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; return ( - + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index e3e7cfa5..b8ad2a9c 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,13 +1,9 @@ -import FilterListIcon from "@mui/icons-material/FilterList"; -import { - Button, - Chip, - CircularProgress, - Stack, - TablePagination, - TextField, - Typography, -} from "@mui/material"; +import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; +import { Plus, SlidersHorizontal } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; +import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { globalCount: number; @@ -33,52 +29,46 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - - Vendors / Technicians - - - {globalCount} vendor entries · {activeCount} active - - {isFetching && !isLoading && } - - - - onSearchChange(e.target.value)} - placeholder="Search name, company, trade..." - className="min-w-[260px]" - /> - - - - + + } + /> ); } @@ -90,16 +80,21 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; + resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ + chips, + resultCount, + onClearAll, +}: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - Applied: - + + {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "vendor entry" : "vendor entries"} + {totalCount === 1 ? "technician" : "technicians"} {isFiltered && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[12, 24, 48]} - /> + + + {activeCount} active · {inactiveCount} inactive + + {totalCount > pageSize && ( + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[24, 48, 96]} + /> + )} ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 802fff8a..db588cff 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,11 +1,10 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Chip, + Box, CircularProgress, IconButton, Link, @@ -18,8 +17,8 @@ import { TableHead, TableRow, Tooltip, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -30,9 +29,6 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", - "Status", - "View", - "Edit", ] as const; function getInitials(name: string): string { @@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } +function VendorStatus({ isActive }: { isActive: boolean }) { + return ( + + + {isActive ? "Active" : "Inactive"} + + ); +} + interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { - const navigate = useNavigate(); +function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo onKeyDown={handleKeyDown} > - {row.companyName || "—"} + + {row.companyName || "—"} + - + {getInitials(row.contactName)} - {row.contactName || "—"} + + {row.contactName || "—"} + - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && } + {extraTrades > 0 && ( + + +{extraTrades} + + )} )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + { event.stopPropagation(); - navigate( - row.id != null - ? `/vendors/edit/${row.id}` - : `/vendors/company/${row.companyId ?? ""}`, - ); + onOpenEdit(row); }} > - {hasVendorId && row.isActive && ( - - { - event.stopPropagation(); - onOpenDeactivation(row); - }} - > - - - - )} @@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo interface VendorsTableProps { rows: VendorListItem[]; + isActive: boolean; + isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } export function VendorsTable({ rows, + isActive, + isFiltered, isLoading, showError, onOpenDetail, - onOpenDeactivation, + onOpenEdit, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; + const columnCount = TABLE_COLUMNS.length + 2; return ( - - + +
    + + + + + + + + + + + {TABLE_COLUMNS.map((column) => ( - + {column} ))} + Status + {isLoading ? ( - + ) : showEmptyRow ? ( - - - No vendors match the current filters. - + + + + + {`No ${isActive ? "active" : "inactive"} technicians found`} + + + {isFiltered + ? "Try a different search, or add a new vendor." + : "Add a vendor to get started."} + + ) : ( @@ -252,7 +338,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenDeactivation={onOpenDeactivation} + onOpenEdit={onOpenEdit} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a084b59b..48447ad6 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,16 +23,91 @@ interface AppliedChip { label: string; } +function statusTabLabel(label: string, count: number) { + return ( + + {label} + + {count} + + + ); +} + +function VendorStatusTabs({ + isActive, + activeCount, + inactiveCount, + onChange, +}: { + isActive: boolean; + activeCount: number; + inactiveCount: number; + onChange: (isActive: boolean) => void; +}) { + return ( + onChange(value === "active")} + aria-label="Vendor status" + sx={{ + minHeight: 36, + flexShrink: 0, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + backgroundColor: "var(--color-bg-surface)", + mx: -2, + px: 1.5, + "& .MuiTab-root": { + minHeight: 36, + px: 2, + py: 0, + fontSize: 13, + fontWeight: 500, + textTransform: "none", + }, + "& .MuiTab-root:first-of-type": { width: 102.84375 }, + "& .Mui-selected": { fontWeight: 600 }, + "& .MuiTabs-indicator": { height: 2 }, + }} + > + + + + ); +} + function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); - filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); - filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); + filters.companies.forEach((value) => + chips.push({ category: "companies", value, label: `Company: ${value}` }), + ); + filters.trades.forEach((value) => + chips.push({ category: "trades", value, label: `Trade: ${value}` }), + ); + filters.locations.forEach((value) => + chips.push({ category: "locations", value, label: `Location: ${value}` }), + ); filters.jobBuckets.forEach((value) => - chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + chips.push({ + category: "jobBuckets", + value, + label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, + }), ); return chips; } @@ -42,7 +117,12 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const deactivation = useVendorDeactivation(); + const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); + const closeDetail = () => { + setDetailTarget(null); + setDetailStartsInEdit(false); + }; + const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -61,7 +141,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - - filters.setIsActiveWithReset(value === "active") - } - aria-label="Vendor status" - > - - - + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -102,10 +179,18 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={setDetailTarget} - onOpenDeactivation={deactivation.open} + onOpenDetail={(row) => { + setDetailStartsInEdit(false); + setDetailTarget(row); + }} + onOpenEdit={(row) => { + setDetailStartsInEdit(true); + setDetailTarget(row); + }} /> setDetailTarget(null)} + startInEdit={detailStartsInEdit} + onRequestDeactivation={deactivation.open} + onClose={closeDetail} /> @@ -49,15 +50,31 @@ export function PageHeader({ "m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground", eyebrow && "mt-1", )} + sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }} > {title} - + {subtitle} {Boolean(actions) && ( - + {actions} )} 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 e083f352..2e9f8f1f 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 @@ -5,6 +5,7 @@ import { useController } from "react-hook-form"; import { beforeEach, describe, expect, it, vi } from "vitest"; const rosterGet = vi.fn(); +const saveMutate = vi.fn(); vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, @@ -28,7 +29,10 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => const actual = await vi.importActual< typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") >("@/domain/vendors/use-cases/use-save-vendor-company-roster"); - return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; + return { + ...actual, + useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }), + }; }); import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; @@ -101,6 +105,7 @@ function deferRoster(): { describe("useVendorRosterForm load-error handling", () => { beforeEach(() => { rosterGet.mockReset(); + saveMutate.mockReset(); }); it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { @@ -196,6 +201,44 @@ describe("useVendorRosterForm load-error handling", () => { }); }); +describe("useVendorRosterForm prototype defaults", () => { + it("does not persist the prototype's untouched optional technician card", () => { + const { result } = renderHook( + () => useVendorRosterForm({ mode: "create", startWithTechnician: true }), + { wrapper: makeWrapper(createClient()) }, + ); + + act(() => { + result.current.submit({ + name: "Independent Vendor LLC", + companyPhone: "(314) 555-0199", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, + }, + ], + }); + }); + + expect(saveMutate).toHaveBeenCalledWith( + expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }), + expect.any(Object), + ); + }); +}); + describe("useVendorRosterForm stale-selection handling", () => { beforeEach(() => { rosterGet.mockReset(); 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 a55ddaf0..df329a4a 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,11 +49,11 @@ function rosterWith(technicians: Array>) { }; } -describe("VendorDetailDrawer preferred-contact display", () => { +describe("VendorDetailDrawer selected-technician display", () => { it("renders no preference label when preferredContact is absent", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ - { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + { id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, ]), ); @@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => { withAuth: false, }); - expect(screen.getByText("Adam")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); }); - it("renders the preference label when an explicit preferredContact is present", () => { + it("keeps the retired preferred-contact value out of the detail view", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ { - id: 7, + id: 1, contactName: "Adam", phone: "314-555-0198", preferredContact: "Email", @@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => { withAuth: false, }); - expect(screen.getByText("Prefers Email")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); + expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); + }); + + it("falls back to the clicked row when the roster has no matching stable id", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Adam Whyte", + phone: "999-999-9999", + isActive: true, + totalJobs: 99, + }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument(); + }); + + it("does not expose a status control for a different technician", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Different Technician", + phone: "314-555-0101", + email: "", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 10, + }, + ]), + ); + + renderWithProviders( + , + { route: "/vendors", withAuth: false }, + ); + + expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument(); }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 64c71c69..a5fd7a64 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -1,21 +1,14 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); -const navigate = vi.fn(); - -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router"); - return { ...actual, useNavigate: () => navigate }; -}); - vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); @@ -61,6 +54,32 @@ const inactiveVendor = { isActive: false, }; +const activeRoster: VendorCompanyRoster = { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gatewayplumbing.com", + address: "", + city: "St. Louis", + state: "MO", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + preferredContact: "Email", + tradeSpecialties: "Backflow Preventers", + isActive: true, + totalJobs: 0, + }, + ], +}; + function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, @@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) { } function setupDefaults() { - useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); + useVendorCompanyRoster.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorDeactivationImpact.mockReturnValue({ data: undefined, isLoading: false, @@ -80,7 +105,7 @@ function setupDefaults() { } describe("VendorsListPage", () => { - it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { + it("renders prototype-aligned status and two row actions in both tabs", async () => { setupDefaults(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); @@ -91,11 +116,20 @@ describe("VendorsListPage", () => { expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "View vendor Gateway Plumbing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" })); expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); @@ -114,39 +148,18 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); + expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ - data: { - companyId: "co-1", - rowVersion: "rv-1", - name: "Gateway Plumbing", - companyPhone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - address: "", - city: "St. Louis", - state: "MO", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - id: 1, - contactName: "Adam Whyte", - phone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - preferredContact: "Email", - tradeSpecialties: "Backflow Preventers", - isActive: true, - }, - ], - }, + data: activeRoster, isLoading: false, + isError: false, error: null, + refetch: vi.fn(), }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); @@ -161,11 +174,18 @@ describe("VendorsListPage", () => { row.focus(); await userEvent.keyboard("{Enter}"); - expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); }); it("blocks deactivation when the preflight reports open work orders", async () => { setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorDeactivationImpact.mockReturnValue({ data: { vendorId: 1, @@ -189,7 +209,8 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); expect( screen.getByText(/cannot be deactivated because it still has open work orders/), @@ -199,9 +220,51 @@ describe("VendorsListPage", () => { expect(mutate).not.toHaveBeenCalled(); }); - it("navigates to the vendor edit route when the table Edit control is clicked", async () => { + it("preserves inline edits when deactivation is cancelled", async () => { setupDefaults(); - navigate.mockClear(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useVendorDeactivationImpact.mockReturnValue({ + data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] }, + isLoading: false, + error: null, + }); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + const company = screen.getByRole("textbox", { name: "Company" }); + await userEvent.clear(company); + await userEvent.type(company, "Draft Company Name"); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await userEvent.click( + within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { + name: "Cancel", + }), + ); + + expect(company).toBeInTheDocument(); + expect(company).toHaveValue("Draft Company Name"); + }); + + it("opens inline edit when the table Edit control is clicked", async () => { + setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([activeVendor], 1) : result([], 0); @@ -211,16 +274,20 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); - expect( - screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); }); - it("navigates to the company route when editing a vendor without a vendor id", async () => { + it("opens inline company edit when the row has no vendor id", async () => { setupDefaults(); - navigate.mockClear(); const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; + useVendorCompanyRoster.mockReturnValue({ + data: { ...activeRoster, companyId: "co-9", technicians: [] }, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); @@ -230,6 +297,7 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); + expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); }); }); diff --git a/tmp/pr-descriptions/pr3-uplifts-column-tab.md b/tmp/pr-descriptions/pr3-uplifts-column-tab.md deleted file mode 100644 index d0fa7a36..00000000 --- a/tmp/pr-descriptions/pr3-uplifts-column-tab.md +++ /dev/null @@ -1,46 +0,0 @@ -## Jira - -- [SH-196](https://luby-us.atlassian.net/browse/SH-196) - -## QA residual - -- Conscious adaptation: **dialog** vs design slide-over tab (no slide-over on this `dev`). -- P0 done: admin revoke of `approved` with required reason. -- P1: `+ Add` / Manage + attachments polish. - -## Summary - -Adds the **UPLIFT** column to the work orders board table (after COMP DOC) with a status pill that opens a **uplifts dialog** (not a slide-over tab — board currently navigates to `/workorders/:id` on row detail). - -- Maps `upliftSummary` on each board row: `{ hasUplift, pendingCount, primaryStatus?, amount? }` -- Dialog: list uplifts, create (amount/notes + attachments UI stub), cancel pending, revoke `auto_approved` (optional reason) and `approved` for **admin** (required reason, SH-196); read-only when status is Completed/Canceled -- WO-scoped API client + query keys under `workorders/{id}/uplifts` -- DEV in-memory mock fallback when API calls fail - -## API / assumptions - -| Item | Assumption | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| UI surface | **Dialog** from UPLIFT column click — design ref uses slide-over tab, but `dev` has no slide-over; detail opens via route navigation | -| Row field | `upliftSummary` on GET `/board` and `/board/search` rows (see `docs/work-orders/pending-be-contract.md`) | -| Endpoints | `GET/POST workorders/{id}/uplifts`, `POST …/uplifts/{id}/cancel\|revoke` — distinct from NTE queue `uplifts` API | -| Attachments | Create form shows disabled attachments field until BE accepts files | -| DEV mock | When `import.meta.env.DEV` and API throws, `work-order-uplifts-mock-store` serves list/create/cancel/revoke | - -## Out of scope (later PRs) - -- Closability / Comp Doc pending gate (PR4) -- Advanced filter uplift facets (PR5) -- Vendor Portal NTE queue - -## Test plan - -- [ ] Board row shows UPLIFT pill after COMP DOC (pending count or primary status + amount) -- [ ] Click UPLIFT opens dialog; Completed/Canceled rows are read-only -- [ ] Create uplift (DEV mock) updates list; cancel pending / revoke auto_approved work -- [ ] Admin can revoke `approved` uplifts with required reason; non-admin cannot -- [ ] `npm run verify` green - -## Design ref - -`seahaven-desing` — `WorkOrders.tsx` `UpliftCell` (~L4669) From f5414e4b1454899715c7bdc6cd4912b5fd4c42d8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 09:51:32 -0300 Subject: [PATCH 06/31] feat(work-orders): block completed when uplift is pending --- .../list/table/cells/status-cell.tsx | 52 +++++++---- .../mappers/work-order-table-row-mapper.ts | 46 ++++++++++ .../work-orders/types/work-order-table-row.ts | 8 ++ .../work-orders/utils/wo-closability.ts | 42 +++++++++ .../status-cell-pending-uplift.test.tsx | 90 +++++++++++++++++++ .../mappers/work-order-mapper.test.ts | 28 ++++++ .../work-orders/utils/wo-closability.test.ts | 74 +++++++++++++++ .../pr4-uplift-pending-close-gate.md | 30 +++++++ 8 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 src/domain/work-orders/utils/wo-closability.ts create mode 100644 src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx create mode 100644 src/test/domain/work-orders/utils/wo-closability.test.ts create mode 100644 tmp/pr-descriptions/pr4-uplift-pending-close-gate.md diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index d2e4c78a..7f4866bf 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -11,6 +11,10 @@ import { } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + formatClosabilityMissingTitle, + getClosabilityGaps, +} from "@/domain/work-orders/utils/wo-closability"; import { HoverChevron } from "./hover-chevron"; @@ -36,6 +40,7 @@ export function StatusCell({ onPastDueAttempt, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); + const closabilityGaps = getClosabilityGaps(wo); if (wo.isPastDue) { return ( @@ -134,24 +139,39 @@ export function StatusCell({ onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }} > - {ALL_WIZARD_STATUSES.map((status) => ( - - ))} + {wo.status === status && ( + + )} + + ); + })} ); diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index a12162c2..cd411b3e 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -7,6 +7,7 @@ import { import type { CompletionDocStatus, WorkOrderTableRow, + WorkOrderUpliftSummary, } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; @@ -174,6 +175,47 @@ function readFlagColor(record: Record): string | null { return null; } +function readUpliftSummary(item: Record): WorkOrderUpliftSummary | undefined { + const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); + const source = Object.keys(nested).length > 0 ? nested : item; + + const pendingCount = + readNumber(source, "pendingCount", "PendingCount") ?? + readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? + 0; + const hasUplift = + readBool(source, "hasUplift", "HasUplift") || + readBool(item, "hasUplift", "HasUplift") || + pendingCount > 0; + + if (!hasUplift && pendingCount === 0) { + return undefined; + } + + const summary: WorkOrderUpliftSummary = { + hasUplift, + pendingCount, + }; + const primaryStatus = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); + if (primaryStatus) { + summary.primaryStatus = primaryStatus; + } + return summary; +} + +function readHasPendingUplift( + item: Record, + upliftSummary: WorkOrderUpliftSummary | undefined, +): boolean | undefined { + if (readBool(item, "hasPendingUplift", "HasPendingUplift")) { + return true; + } + if ((upliftSummary?.pendingCount ?? 0) > 0) { + return true; + } + return undefined; +} + export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const item = asRecord(raw); const scheduledOn = toDateInputValue( @@ -228,6 +270,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const locationIdRaw = item.locationId ?? item.LocationId; const vendorIdNum = readNumber(item, "vendorId", "VendorId"); const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId"); + const upliftSummary = readUpliftSummary(item); + const hasPendingUplift = readHasPendingUplift(item, upliftSummary); const row: WorkOrderTableRow = { id: readId(item), @@ -272,6 +316,8 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { rowVersion: readRowVersion(item, "rowVersion", "RowVersion"), dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"), flagColor: readFlagColor(item), + upliftSummary, + hasPendingUplift, }; return row; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..7e533a6b 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -4,6 +4,12 @@ export type CompletionDocStatus = "Yes" | "No" | "NN" | ""; export const WORK_ORDERS_TABLE_PAGE_SIZE = 100; +export type WorkOrderUpliftSummary = { + hasUplift: boolean; + pendingCount: number; + primaryStatus?: string; +}; + export interface WorkOrderTableRow { id: string | number; woNumber: string; @@ -49,6 +55,8 @@ export interface WorkOrderTableRow { dispatchRowVersion: string; /** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */ flagColor: string | null; + upliftSummary?: WorkOrderUpliftSummary | null; + hasPendingUplift?: boolean; } export interface WorkOrderTableDraft { diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts new file mode 100644 index 00000000..31c7d180 --- /dev/null +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -0,0 +1,42 @@ +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const PENDING_UPLIFT_GAP = "Pending uplift approval"; + +/** Fields required to evaluate whether a WO may be marked Completed or generate a comp doc. */ +export type WoClosabilityInput = Pick; + +export function hasPendingUplift(row: WoClosabilityInput): boolean { + if (row.hasPendingUplift === true) { + return true; + } + return (row.upliftSummary?.pendingCount ?? 0) > 0; +} + +/** + * Returns human-readable labels for requirements still missing before Completed is allowed. + * Empty array means the WO may be marked Completed (for closability gates implemented here). + */ +export function getClosabilityGaps(row: WoClosabilityInput): string[] { + if (hasPendingUplift(row)) { + return [PENDING_UPLIFT_GAP]; + } + return []; +} + +export function formatClosabilityMissingTitle(gaps: string[]): string { + return `Missing: ${gaps.join(", ")}`; +} + +export type CompDocGateResult = { allowed: true } | { allowed: false; reason: string }; + +/** + * Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this + * before opening generate flow and surface `reason` when blocked. + */ +export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { + const gaps = getClosabilityGaps(row); + if (gaps.length > 0) { + return { allowed: false, reason: formatClosabilityMissingTitle(gaps) }; + } + return { allowed: true }; +} diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx new file mode 100644 index 00000000..11659ae9 --- /dev/null +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: 1, + woNumber: "1001", + site: "BK5", + type: "PM", + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + targetWeek: "2026-07-15", + dayGroup: "07/15", + dayLabel: "Tue", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "In Progress", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + ...overrides, + }; +} + +describe("StatusCell pending uplift closability", () => { + it("disables Completed with tooltip when uplift is pending", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute("title", "Missing: Pending uplift approval"); + + fireEvent.click(completed); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + + it("allows Completed when no pending uplift", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index 63a92912..1b3baa4e 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -135,4 +135,32 @@ describe("mapWorkOrderTableRow (board DTO)", () => { expect(mapWorkOrderTableRow({ id: 1, flagColor: "" }).flagColor).toBeNull(); expect(mapWorkOrderTableRow({ id: 1, FlagColor: "#3B82F6" }).flagColor).toBe("#3B82F6"); }); + + it("maps uplift summary and pending flag from API payload", () => { + const row = mapWorkOrderTableRow({ + id: 303, + upliftSummary: { + hasUplift: true, + pendingCount: 2, + primaryStatus: "pending", + }, + }); + + expect(row.upliftSummary).toEqual({ + hasUplift: true, + pendingCount: 2, + primaryStatus: "pending", + }); + expect(row.hasPendingUplift).toBe(true); + }); + + it("maps hasPendingUplift from top-level API flag", () => { + const row = mapWorkOrderTableRow({ + id: 304, + hasPendingUplift: true, + }); + + expect(row.hasPendingUplift).toBe(true); + expect(row.upliftSummary).toBeUndefined(); + }); }); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts new file mode 100644 index 00000000..f3a3a353 --- /dev/null +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; +import { + canGenerateCompDoc, + formatClosabilityMissingTitle, + getClosabilityGaps, + hasPendingUplift, +} from "@/domain/work-orders/utils/wo-closability"; + +function makeInput(overrides: Partial = {}): WoClosabilityInput { + return { + upliftSummary: undefined, + hasPendingUplift: undefined, + ...overrides, + }; +} + +describe("hasPendingUplift", () => { + it("is true when hasPendingUplift flag is set", () => { + expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true); + }); + + it("is true when upliftSummary.pendingCount is positive", () => { + expect( + hasPendingUplift(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 2 } })), + ).toBe(true); + }); + + it("is false when no uplift signals are present", () => { + expect(hasPendingUplift(makeInput())).toBe(false); + expect( + hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })), + ).toBe(false); + }); +}); + +describe("getClosabilityGaps", () => { + it("returns empty when no pending uplift", () => { + expect(getClosabilityGaps(makeInput())).toEqual([]); + }); + + it("blocks Completed when uplift is pending via summary", () => { + expect( + getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })), + ).toEqual(["Pending uplift approval"]); + }); + + it("blocks Completed when hasPendingUplift is true", () => { + expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([ + "Pending uplift approval", + ]); + }); +}); + +describe("formatClosabilityMissingTitle", () => { + it("joins gaps for tooltip copy", () => { + expect(formatClosabilityMissingTitle(["Pending uplift approval"])).toBe( + "Missing: Pending uplift approval", + ); + }); +}); + +describe("canGenerateCompDoc", () => { + it("allows generation when no closability gaps", () => { + expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true }); + }); + + it("blocks generation with reason when pending uplift exists", () => { + expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({ + allowed: false, + reason: "Missing: Pending uplift approval", + }); + }); +}); diff --git a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md new file mode 100644 index 00000000..d9bf1215 --- /dev/null +++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md @@ -0,0 +1,30 @@ +# PR4 — Pending uplift close gate + +## Summary + +Blocks marking a work order **Completed** (and future completion-document generation) while an uplift request is still pending approval. + +- Adds optional `upliftSummary` / `hasPendingUplift` on `WorkOrderTableRow`, mapped from the board API when present. +- Introduces `getClosabilityGaps`, `formatClosabilityMissingTitle`, and `canGenerateCompDoc` in `wo-closability.ts` (pending-uplift scope only on this base). +- Wires the board **Status** popover to disable **Completed** with tooltip `Missing: Pending uplift approval` when a gap exists. + +## Comp Doc Generate + +`CompDocDialog` is **not** mounted on `dev` yet. This PR exports `canGenerateCompDoc(row)` so the completion-doc slice can block generate/open with the same reason string. No UI wiring in this PR. + +## Out of scope + +- Full uplift CRUD (PR3) +- Advanced filter facets for uplift +- Full closability matrix (dispatcher, comp doc, photos, etc.) — separate gate work + +## Test plan + +- [ ] Row with `hasPendingUplift: true` — Status menu shows Completed disabled; tooltip explains missing pending uplift approval. +- [ ] Row without pending uplift — Completed remains selectable. +- [ ] `canGenerateCompDoc` returns `{ allowed: false, reason: "Missing: Pending uplift approval" }` when pending uplift is present. +- [ ] Board API payload with `upliftSummary.pendingCount > 0` maps through to closability gate. + +## Base + +Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Does not depend on PR3 branch git-wise; uplift fields are optional stubs until BE/PR3 land. From 6b415f732c7d6660d385b6a98e120e0e68a19d51 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 13:43:35 -0300 Subject: [PATCH 07/31] fix(work-orders): unify closability with sh-114 and pending uplift --- .../list/table/cells/status-cell.tsx | 14 ++- .../mappers/work-order-table-row-mapper.ts | 3 + .../work-orders/types/work-order-table-row.ts | 2 + .../work-orders/utils/wo-closability.ts | 115 ++++++++++++++++-- .../status-cell-pending-uplift.test.tsx | 10 +- .../work-orders/utils/wo-closability.test.ts | 77 ++++++++++-- .../pr4-uplift-pending-close-gate.md | 31 +++-- 7 files changed, 217 insertions(+), 35 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 7f4866bf..28049593 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -40,7 +40,19 @@ export function StatusCell({ onPastDueAttempt, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); - const closabilityGaps = getClosabilityGaps(wo); + const closabilityGaps = getClosabilityGaps({ + dispatcherId: wo.dispatcherId, + dispatcherName: wo.dispatcherName, + tech: wo.tech, + completedDate: wo.completedDate ?? "", + woNumber: wo.woNumber, + pm: wo.pm, + docStatus: wo.docStatus, + mediaCount: wo.mediaCount, + type: wo.type, + upliftSummary: wo.upliftSummary, + hasPendingUplift: wo.hasPendingUplift, + }); if (wo.isPastDue) { return ( diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index cd411b3e..3b9fc66b 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -281,6 +281,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { dispatcherId, dispatcherName, scheduledOn, + completedDate: toDateInputValue( + readString(item, "completedDate", "CompletedDate", "completedOn", "CompletedOn"), + ), targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")), dayGroup, dayLabel, diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 7e533a6b..ff18a232 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -18,6 +18,8 @@ export interface WorkOrderTableRow { dispatcherId: string; dispatcherName: string; scheduledOn: string; + /** Work completed date (API CompletedDate); used by closability / COMPLETED DATE column. */ + completedDate?: string; targetWeek: string; dayGroup: string; dayLabel: string; diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 31c7d180..5f1c67c6 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -1,29 +1,109 @@ -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { + CompletionDocStatus, + WorkOrderUpliftSummary, +} from "@/domain/work-orders/types/work-order-table-row"; +import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; -const PENDING_UPLIFT_GAP = "Pending uplift approval"; +/** Fields required to evaluate whether a WO may be marked Completed (SH-114 ∪ pending uplift). */ +export type WoClosabilityInput = { + dispatcherId: string; + dispatcherName: string; + /** Vendor / technician assignment on the board row. */ + tech: string; + /** Work completed date (API CompletedDate). Empty until set. */ + completedDate: string; + woNumber: string; + pm: string; + docStatus: CompletionDocStatus; + mediaCount: number; + type: WOType | ""; + upliftSummary?: WorkOrderUpliftSummary | null; + hasPendingUplift?: boolean; +}; -/** Fields required to evaluate whether a WO may be marked Completed or generate a comp doc. */ -export type WoClosabilityInput = Pick; +export const PENDING_UPLIFT_GAP = "Pending uplift approval"; -export function hasPendingUplift(row: WoClosabilityInput): boolean { +/** Design copy when Completed is blocked solely by a pending uplift. */ +export const UPLIFT_PENDING_COMPLETED_TITLE = + "This work order cannot be completed while an uplift is pending approval."; + +/** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */ +export function isRealWoNumber(woNumber: string): boolean { + const trimmed = woNumber.trim(); + if (!trimmed) { + return false; + } + return !/^SH/i.test(trimmed); +} + +export function hasPendingUplift( + row: Pick, +): boolean { if (row.hasPendingUplift === true) { return true; } return (row.upliftSummary?.pendingCount ?? 0) > 0; } +function hasDispatcher(row: Pick): boolean { + return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); +} + +function hasService(pm: string): boolean { + return Boolean(pm.trim()); +} + +function hasCompletionDocument(docStatus: CompletionDocStatus): boolean { + return docStatus === "Yes" || docStatus === "NN"; +} + /** - * Returns human-readable labels for requirements still missing before Completed is allowed. - * Empty array means the WO may be marked Completed (for closability gates implemented here). + * Gaps that block Completed, in design hierarchy: + * fields → uplift pending → completion document / photos. + * (Scheduled-date gate is a separate design P1 — not in this module yet.) */ export function getClosabilityGaps(row: WoClosabilityInput): string[] { - if (hasPendingUplift(row)) { - return [PENDING_UPLIFT_GAP]; + const gaps: string[] = []; + + if (!hasDispatcher(row)) { + gaps.push("Dispatcher"); } - return []; + if (!row.tech.trim()) { + gaps.push("Technician"); + } + if (!row.completedDate.trim()) { + gaps.push("Completed Date"); + } + if (!isRealWoNumber(row.woNumber)) { + gaps.push("WO number"); + } + if (!hasService(row.pm)) { + gaps.push("Service"); + } + + if (hasPendingUplift(row)) { + gaps.push(PENDING_UPLIFT_GAP); + } + + if (row.type === "PM" || row.type === "Overdue") { + if (!hasCompletionDocument(row.docStatus)) { + gaps.push("Completion document"); + } + } + + if (row.type === "Emergency" || row.type === "Reactive") { + if (row.mediaCount < 1) { + gaps.push("At least 1 photo"); + } + } + + return gaps; } export function formatClosabilityMissingTitle(gaps: string[]): string { + if (gaps.length === 1 && gaps[0] === PENDING_UPLIFT_GAP) { + return UPLIFT_PENDING_COMPLETED_TITLE; + } return `Missing: ${gaps.join(", ")}`; } @@ -40,3 +120,18 @@ export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { } return { allowed: true }; } + +/** Map create-wizard draft onto the shared closability contract. */ +export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoClosabilityInput { + return { + dispatcherId: draft.assigneeId, + dispatcherName: draft.assigneeName, + tech: draft.vendorName, + completedDate: "", + woNumber: draft.woNumber, + pm: draft.pm, + docStatus: "", + mediaCount: 0, + type: draft.type, + }; +} diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index 11659ae9..998b2680 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -13,6 +13,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow dispatcherId: "u1", dispatcherName: "Alex", scheduledOn: "2026-07-15", + completedDate: "2026-07-20", targetWeek: "2026-07-15", dayGroup: "07/15", dayLabel: "Tue", @@ -29,7 +30,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow apptEnd: "", apptTime: "", status: "In Progress", - docStatus: "No", + docStatus: "Yes", pocName: "", pocPhone: "", pocNotes: "", @@ -40,7 +41,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow originalDate: "", isPastDue: false, scheduleWeekOnly: false, - mediaCount: 0, + mediaCount: 1, title: "", priority: "", primaryDispatchId: null, @@ -68,7 +69,10 @@ describe("StatusCell pending uplift closability", () => { const completed = screen.getByRole("button", { name: /completed/i }); expect(completed).toBeDisabled(); - expect(completed).toHaveAttribute("title", "Missing: Pending uplift approval"); + expect(completed).toHaveAttribute( + "title", + "This work order cannot be completed while an uplift is pending approval.", + ); fireEvent.click(completed); expect(onChangeStatus).not.toHaveBeenCalled(); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index f3a3a353..c0b24abb 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -1,20 +1,41 @@ import { describe, expect, it } from "vitest"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import { + PENDING_UPLIFT_GAP, + UPLIFT_PENDING_COMPLETED_TITLE, canGenerateCompDoc, formatClosabilityMissingTitle, getClosabilityGaps, hasPendingUplift, + isRealWoNumber, } from "@/domain/work-orders/utils/wo-closability"; function makeInput(overrides: Partial = {}): WoClosabilityInput { return { + dispatcherId: "u1", + dispatcherName: "Alex", + tech: "Sam", + completedDate: "2026-07-01", + woNumber: "1001", + pm: "HVAC", + docStatus: "Yes", + mediaCount: 1, + type: "PM", upliftSummary: undefined, hasPendingUplift: undefined, ...overrides, }; } +describe("isRealWoNumber", () => { + it("rejects empty and SH-prefixed provisional numbers", () => { + expect(isRealWoNumber("")).toBe(false); + expect(isRealWoNumber("SH-9")).toBe(false); + expect(isRealWoNumber("sh123")).toBe(false); + expect(isRealWoNumber("98765")).toBe(true); + }); +}); + describe("hasPendingUplift", () => { it("is true when hasPendingUplift flag is set", () => { expect(hasPendingUplift(makeInput({ hasPendingUplift: true }))).toBe(true); @@ -35,27 +56,63 @@ describe("hasPendingUplift", () => { }); describe("getClosabilityGaps", () => { - it("returns empty when no pending uplift", () => { + it("returns empty when all gates are satisfied", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); + it("lists field gaps before uplift and document gaps", () => { + expect( + getClosabilityGaps( + makeInput({ + dispatcherId: "", + dispatcherName: "", + tech: " ", + completedDate: "", + woNumber: "SH-1", + pm: "", + hasPendingUplift: true, + docStatus: "No", + type: "PM", + }), + ), + ).toEqual([ + "Dispatcher", + "Technician", + "Completed Date", + "WO number", + "Service", + PENDING_UPLIFT_GAP, + "Completion document", + ]); + }); + it("blocks Completed when uplift is pending via summary", () => { expect( getClosabilityGaps(makeInput({ upliftSummary: { hasUplift: true, pendingCount: 1 } })), - ).toEqual(["Pending uplift approval"]); + ).toEqual([PENDING_UPLIFT_GAP]); }); it("blocks Completed when hasPendingUplift is true", () => { - expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([ - "Pending uplift approval", - ]); + expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); + }); + + it("requires a photo for Emergency / Reactive", () => { + expect( + getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })), + ).toEqual(["At least 1 photo"]); }); }); describe("formatClosabilityMissingTitle", () => { - it("joins gaps for tooltip copy", () => { - expect(formatClosabilityMissingTitle(["Pending uplift approval"])).toBe( - "Missing: Pending uplift approval", + it("uses design Completed title when only uplift is missing", () => { + expect(formatClosabilityMissingTitle([PENDING_UPLIFT_GAP])).toBe( + UPLIFT_PENDING_COMPLETED_TITLE, + ); + }); + + it("joins multiple gaps with Missing prefix", () => { + expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe( + `Missing: Dispatcher, ${PENDING_UPLIFT_GAP}`, ); }); }); @@ -65,10 +122,10 @@ describe("canGenerateCompDoc", () => { expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true }); }); - it("blocks generation with reason when pending uplift exists", () => { + it("blocks generation with design reason when only pending uplift exists", () => { expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({ allowed: false, - reason: "Missing: Pending uplift approval", + reason: UPLIFT_PENDING_COMPLETED_TITLE, }); }); }); diff --git a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md index d9bf1215..13a8b08c 100644 --- a/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md +++ b/tmp/pr-descriptions/pr4-uplift-pending-close-gate.md @@ -1,12 +1,19 @@ -# PR4 — Pending uplift close gate +# PR4 — Pending uplift close gate (+ SH-114 closability union) ## Summary -Blocks marking a work order **Completed** (and future completion-document generation) while an uplift request is still pending approval. +Blocks marking a work order **Completed** (and future completion-document generation) when closability gaps remain — **SH-114 field gates ∪ pending uplift** (SH-188). -- Adds optional `upliftSummary` / `hasPendingUplift` on `WorkOrderTableRow`, mapped from the board API when present. -- Introduces `getClosabilityGaps`, `formatClosabilityMissingTitle`, and `canGenerateCompDoc` in `wo-closability.ts` (pending-uplift scope only on this base). -- Wires the board **Status** popover to disable **Completed** with tooltip `Missing: Pending uplift approval` when a gap exists. +- Unifies `wo-closability.ts` / `getClosabilityGaps` with hierarchy: **fields → uplift pending → completion document / photos**. +- Field gates: dispatcher, technician, completed date, real WO# (non-SH), service; then PM/Overdue completion doc; Emergency/Reactive ≥1 photo. +- Pending uplift via `hasPendingUplift` or `upliftSummary.pendingCount`. +- Completed tooltip uses design copy when the **only** gap is pending uplift. +- Maps optional `completedDate` on board rows for the Completed Date gate. + +## Jira + +- [SH-188](https://luby-us.atlassian.net/browse/SH-188) — pending uplift close gate +- Related: [SH-114](https://luby-us.atlassian.net/browse/SH-114) — closability field matrix (pulled into this branch so sister merges do not wipe gates) ## Comp Doc Generate @@ -16,15 +23,17 @@ Blocks marking a work order **Completed** (and future completion-document genera - Full uplift CRUD (PR3) - Advanced filter facets for uplift -- Full closability matrix (dispatcher, comp doc, photos, etc.) — separate gate work +- Scheduled-requires-date gate (design P1) +- COL_HELP tooltips (P1) ## Test plan -- [ ] Row with `hasPendingUplift: true` — Status menu shows Completed disabled; tooltip explains missing pending uplift approval. -- [ ] Row without pending uplift — Completed remains selectable. -- [ ] `canGenerateCompDoc` returns `{ allowed: false, reason: "Missing: Pending uplift approval" }` when pending uplift is present. -- [ ] Board API payload with `upliftSummary.pendingCount > 0` maps through to closability gate. +- [ ] Row with all fields ok + `hasPendingUplift: true` — Completed disabled; design tooltip about pending uplift. +- [ ] Row missing dispatcher / completed date — Completed disabled; `Missing: …` tooltip. +- [ ] Row fully closable — Completed selectable. +- [ ] `canGenerateCompDoc` blocked when gaps exist. +- [ ] Board API `completedDate` / `upliftSummary.pendingCount` map through to the gate. ## Base -Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Does not depend on PR3 branch git-wise; uplift fields are optional stubs until BE/PR3 land. +Branch `feature/wo-uplift-pending-close-gate` from local `dev`. Merge **before** or instead of stacking raw SH-114 alone with an uplift-only closability overwrite. From ccfb3d9e6d0662f136e24eab359e33787ab80014 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 3 Aug 2026 14:13:51 -0300 Subject: [PATCH 08/31] fix(work-orders): split status cell to satisfy max-lines-per-function --- .../list/table/cells/status-cell.tsx | 173 +++++++++--------- 1 file changed, 85 insertions(+), 88 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 28049593..67d826ee 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -1,46 +1,28 @@ import { useState } from "react"; - import { AlertTriangle, Check } from "lucide-react"; - import { Popover } from "@mui/material"; import { Text } from "@/components/ui/text"; - import { ALL_WIZARD_STATUSES, type WizardWOStatus, } from "@/domain/work-orders/types/work-order-wizard"; - import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { formatClosabilityMissingTitle, getClosabilityGaps, } from "@/domain/work-orders/utils/wo-closability"; - import { HoverChevron } from "./hover-chevron"; - import { WoTableStatusLabel } from "./wo-table-status-label"; type StatusCellProps = { wo: WorkOrderTableRow; - onChangeStatus: (status: WizardWOStatus) => void; - onOpenSlideOver: () => void; - onPastDueAttempt?: () => void; }; -export function StatusCell({ - wo, - - onChangeStatus, - - onOpenSlideOver, - - onPastDueAttempt, -}: StatusCellProps) { - const [anchorEl, setAnchorEl] = useState(null); - const closabilityGaps = getClosabilityGaps({ +function rowClosabilityGaps(wo: WorkOrderTableRow) { + return getClosabilityGaps({ dispatcherId: wo.dispatcherId, dispatcherName: wo.dispatcherName, tech: wo.tech, @@ -53,73 +35,91 @@ export function StatusCell({ upliftSummary: wo.upliftSummary, hasPendingUplift: wo.hasPendingUplift, }); +} + +function PastDueStatusCell({ + onOpenSlideOver, + onPastDueAttempt, +}: { + onOpenSlideOver: () => void; + onPastDueAttempt?: () => void; +}) { + const [anchorEl, setAnchorEl] = useState(null); + + return ( + <> + + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ + paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" }, + }} + > +
    + +
    + + This work order is past due. Update the Due Date before the status can be updated. + + +
    +
    +
    + + ); +} + +export function StatusCell({ + wo, + onChangeStatus, + onOpenSlideOver, + onPastDueAttempt, +}: StatusCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const closabilityGaps = rowClosabilityGaps(wo); if (wo.isPastDue) { return ( - <> - - - setAnchorEl(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - onClick={(e) => e.stopPropagation()} - slotProps={{ - paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" }, - }} - > -
    - - -
    - - This work order is past due. Update the Due Date before the status can be updated. - - - -
    -
    -
    - + ); } @@ -129,7 +129,6 @@ export function StatusCell({ type="button" onClick={(e) => { e.stopPropagation(); - setAnchorEl(e.currentTarget); }} className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80" @@ -139,7 +138,6 @@ export function StatusCell({ ) : ( — )} - @@ -177,8 +175,7 @@ export function StatusCell({ } > - - {wo.status === status && ( + {Boolean(wo.status === status) && ( )} From a3e9a20477283f98a4bfe2af34332c6e9a9e0a8b Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 4 Aug 2026 11:42:44 -0300 Subject: [PATCH 09/31] fix(work-orders): fail closed on unknown uplift status --- .../list/table/cells/status-cell.tsx | 1 + .../work-orders/utils/wo-closability.ts | 20 ++++++++++++---- .../work-orders/utils/wo-closability.test.ts | 24 +++++++++++++++---- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx index 67d826ee..ddcdbc5e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/status-cell.tsx @@ -25,6 +25,7 @@ function rowClosabilityGaps(wo: WorkOrderTableRow) { return getClosabilityGaps({ dispatcherId: wo.dispatcherId, dispatcherName: wo.dispatcherName, + company: wo.company, tech: wo.tech, completedDate: wo.completedDate ?? "", woNumber: wo.woNumber, diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 5f1c67c6..606f9369 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -8,7 +8,9 @@ import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/wo export type WoClosabilityInput = { dispatcherId: string; dispatcherName: string; - /** Vendor / technician assignment on the board row. */ + /** Vendor/company assignment on the board row. */ + company: string; + /** Technician name is informational and does not satisfy the vendor requirement. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; @@ -22,6 +24,7 @@ export type WoClosabilityInput = { }; export const PENDING_UPLIFT_GAP = "Pending uplift approval"; +export const UPLIFT_STATUS_UNAVAILABLE_GAP = "Uplift status unavailable"; /** Design copy when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = @@ -45,6 +48,12 @@ export function hasPendingUplift( return (row.upliftSummary?.pendingCount ?? 0) > 0; } +function hasKnownUpliftStatus( + row: Pick, +): boolean { + return typeof row.hasPendingUplift === "boolean" || row.upliftSummary != null; +} + function hasDispatcher(row: Pick): boolean { return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); } @@ -68,8 +77,8 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!hasDispatcher(row)) { gaps.push("Dispatcher"); } - if (!row.tech.trim()) { - gaps.push("Technician"); + if (!row.company.trim()) { + gaps.push("Company / vendor"); } if (!row.completedDate.trim()) { gaps.push("Completed Date"); @@ -81,7 +90,9 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { gaps.push("Service"); } - if (hasPendingUplift(row)) { + if (!hasKnownUpliftStatus(row)) { + gaps.push(UPLIFT_STATUS_UNAVAILABLE_GAP); + } else if (hasPendingUplift(row)) { gaps.push(PENDING_UPLIFT_GAP); } @@ -126,6 +137,7 @@ export function wizardDraftToClosabilityInput(draft: WorkOrderWizardDraft): WoCl return { dispatcherId: draft.assigneeId, dispatcherName: draft.assigneeName, + company: draft.vendorName, tech: draft.vendorName, completedDate: "", woNumber: draft.woNumber, diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index c0b24abb..079ac992 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import { PENDING_UPLIFT_GAP, + UPLIFT_STATUS_UNAVAILABLE_GAP, UPLIFT_PENDING_COMPLETED_TITLE, canGenerateCompDoc, formatClosabilityMissingTitle, @@ -14,6 +15,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn return { dispatcherId: "u1", dispatcherName: "Alex", + company: "Acme HVAC", tech: "Sam", completedDate: "2026-07-01", woNumber: "1001", @@ -21,8 +23,7 @@ function makeInput(overrides: Partial = {}): WoClosabilityIn docStatus: "Yes", mediaCount: 1, type: "PM", - upliftSummary: undefined, - hasPendingUplift: undefined, + hasPendingUplift: false, ...overrides, }; } @@ -47,7 +48,7 @@ describe("hasPendingUplift", () => { ).toBe(true); }); - it("is false when no uplift signals are present", () => { + it("is false when uplift is explicitly not pending", () => { expect(hasPendingUplift(makeInput())).toBe(false); expect( hasPendingUplift(makeInput({ upliftSummary: { hasUplift: false, pendingCount: 0 } })), @@ -60,12 +61,21 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); + it("accepts a company without a technician", () => { + expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual([]); + }); + + it("does not let a technician satisfy a missing company requirement", () => { + expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual(["Company / vendor"]); + }); + it("lists field gaps before uplift and document gaps", () => { expect( getClosabilityGaps( makeInput({ dispatcherId: "", dispatcherName: "", + company: " ", tech: " ", completedDate: "", woNumber: "SH-1", @@ -77,7 +87,7 @@ describe("getClosabilityGaps", () => { ), ).toEqual([ "Dispatcher", - "Technician", + "Company / vendor", "Completed Date", "WO number", "Service", @@ -96,6 +106,12 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); }); + it("blocks Completed when the backend does not provide uplift status", () => { + expect( + getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })), + ).toEqual([UPLIFT_STATUS_UNAVAILABLE_GAP]); + }); + it("requires a photo for Emergency / Reactive", () => { expect( getClosabilityGaps(makeInput({ type: "Emergency", mediaCount: 0, docStatus: "No" })), From 67c4ee81190b187e81a9d0e888995eb6487819c2 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 4 Aug 2026 14:49:16 -0300 Subject: [PATCH 10/31] fix(work-orders): only block Completed on evidenced pending uplift Absence of optional uplift fields means no pending, per SH-188. --- src/domain/work-orders/utils/wo-closability.ts | 12 ++---------- .../domain/work-orders/utils/wo-closability.test.ts | 5 ++--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 606f9369..24617591 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -24,7 +24,6 @@ export type WoClosabilityInput = { }; export const PENDING_UPLIFT_GAP = "Pending uplift approval"; -export const UPLIFT_STATUS_UNAVAILABLE_GAP = "Uplift status unavailable"; /** Design copy when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = @@ -48,12 +47,6 @@ export function hasPendingUplift( return (row.upliftSummary?.pendingCount ?? 0) > 0; } -function hasKnownUpliftStatus( - row: Pick, -): boolean { - return typeof row.hasPendingUplift === "boolean" || row.upliftSummary != null; -} - function hasDispatcher(row: Pick): boolean { return Boolean(row.dispatcherId.trim() || row.dispatcherName.trim()); } @@ -90,9 +83,8 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { gaps.push("Service"); } - if (!hasKnownUpliftStatus(row)) { - gaps.push(UPLIFT_STATUS_UNAVAILABLE_GAP); - } else if (hasPendingUplift(row)) { + // Only an actually pending uplift blocks completion; missing uplift fields mean no pending evidence. + if (hasPendingUplift(row)) { gaps.push(PENDING_UPLIFT_GAP); } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 079ac992..a55938e7 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import type { WoClosabilityInput } from "@/domain/work-orders/utils/wo-closability"; import { PENDING_UPLIFT_GAP, - UPLIFT_STATUS_UNAVAILABLE_GAP, UPLIFT_PENDING_COMPLETED_TITLE, canGenerateCompDoc, formatClosabilityMissingTitle, @@ -106,10 +105,10 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ hasPendingUplift: true }))).toEqual([PENDING_UPLIFT_GAP]); }); - it("blocks Completed when the backend does not provide uplift status", () => { + it("allows Completed when uplift status fields are absent (no pending evidence)", () => { expect( getClosabilityGaps(makeInput({ hasPendingUplift: undefined, upliftSummary: undefined })), - ).toEqual([UPLIFT_STATUS_UNAVAILABLE_GAP]); + ).toEqual([]); }); it("requires a photo for Emergency / Reactive", () => { From dd60b1d72e24de8bfce296f96518ead7158713d8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 10 Aug 2026 13:46:33 -0300 Subject: [PATCH 11/31] fix(work-orders): allow comp-doc generate without existing completion document canGenerateCompDoc now ignores the completion-document closability gap so PM/Overdue drafts can enter generation. Co-authored-by: Cursor --- src/domain/work-orders/utils/wo-closability.ts | 4 +++- src/test/domain/work-orders/utils/wo-closability.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 24617591..7259cbf5 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -115,9 +115,11 @@ export type CompDocGateResult = { allowed: true } | { allowed: false; reason: st /** * Gate for completion-document generation. CompDocDialog (not yet on `dev`) should call this * before opening generate flow and surface `reason` when blocked. + * + * Intentionally omits the "Completion document" closability gap — generate is how that gap is filled. */ export function canGenerateCompDoc(row: WoClosabilityInput): CompDocGateResult { - const gaps = getClosabilityGaps(row); + const gaps = getClosabilityGaps(row).filter((gap) => gap !== "Completion document"); if (gaps.length > 0) { return { allowed: false, reason: formatClosabilityMissingTitle(gaps) }; } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index a55938e7..ea06ad4d 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -137,6 +137,10 @@ describe("canGenerateCompDoc", () => { expect(canGenerateCompDoc(makeInput())).toEqual({ allowed: true }); }); + it("allows generation when only the completion document is missing", () => { + expect(canGenerateCompDoc(makeInput({ docStatus: "No" }))).toEqual({ allowed: true }); + }); + it("blocks generation with design reason when only pending uplift exists", () => { expect(canGenerateCompDoc(makeInput({ hasPendingUplift: true }))).toEqual({ allowed: false, From ea747af854c984d2da20ad6d5d9e8e1b3d1a4dc8 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 11 Aug 2026 15:44:34 -0300 Subject: [PATCH 12/31] fix(work-orders): isolate advanced filter facets from vendor collateral Restack SH-121/SH-196 severity and uplift facets on current dev without unrelated Vendor screen changes, and cover public-interface apply plus search totalCount semantics. --- docs/work-orders/board-search-api.md | 35 +++ .../adv-date-range-section.tsx | 50 ++++ .../advanced-search/adv-filter-sections.tsx | 52 ++-- .../adv-parity-filter-sections.tsx | 146 +++++++++ .../advanced-search/adv-severity-list.tsx | 84 ++++++ .../types/work-order-advanced-filters.ts | 27 +- .../work-orders/types/work-order-board.ts | 8 + .../work-orders/types/work-order-severity.ts | 75 +++++ .../types/work-order-uplift-filter.ts | 16 + .../utils/advanced-filter-chip-builders.ts | 283 ++++++++++++++++++ .../utils/advanced-filter-predicates.ts | 16 + .../utils/advanced-filters-utils.ts | 190 +++--------- .../work-orders/utils/board-query-params.ts | 59 +++- ...ers-advanced-filters-banner-count.test.tsx | 25 ++ ...ork-orders-advanced-search-wiring.test.tsx | 38 +++ .../utils/advanced-filters-utils.test.ts | 42 +++ .../utils/board-query-params.test.ts | 44 +++ 17 files changed, 998 insertions(+), 192 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx create mode 100644 src/domain/work-orders/types/work-order-severity.ts create mode 100644 src/domain/work-orders/types/work-order-uplift-filter.ts create mode 100644 src/domain/work-orders/utils/advanced-filter-chip-builders.ts create mode 100644 src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index fde9438b..6e7134f3 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -57,6 +57,41 @@ Paths no FE: `API_PATHS.workOrder.board`, `boardSearch`, `lookupsDispatchers`. | `pmTypes` | `pmTypes` | Strings (labels Problem dropdown) | | `vendorTechs` | `vendorIds` | Integers (resolvidos por companyName) | | `docs` | `docStatuses` | No=2, Yes=1, NN=3 | +| `severities` | `severities` | Integers `1`–`5` (Emergency/Reactive) | +| `rescheduled` | `rescheduled` | `true` = reschedule count ≥ 2 | +| `carriedOver` | `carriedOver` | `true` = carried-over count ≥ 2 | +| `addOn` | `addOn` | `true` = `isAddOn` indicator | +| `flagColors` | `flagColors` | Multi `#RRGGBB` (board flag palette) | +| `internalOnly` | `internalOnly` | `true` = WO# starts with `SH` | +| `hasUplift` | `hasUplift` | `true` = WO has standing uplift | +| `upliftStatuses` | `upliftStatuses` | With `hasUplift`; see below | + +### `severities` + +Multi-select SEV 1–5. Applies to Emergency/Reactive work orders with a transcribed APM severity. + +### Indicators (`rescheduled`, `carriedOver`, `addOn`) + +Boolean flags matching board badge thresholds (counters ≥ 2 for reschedule/carried-over). `addOn` filters the `isAddOn` indicator (not a WO type). + +### `flagColors` + +Repeated `#RRGGBB` values from the fixed board flag palette (`WorkOrderFlagColors`). + +### `internalOnly` + +When `true`, only work orders whose number starts with `SH` (internal placeholder). + +### Uplift filters + +| FE state | API param | Regra | +| ---------------- | ---------------- | ------------------------------------------------ | +| `hasUplift` | `hasUplift` | `true` = WO has non-cancelled/non-revoked uplift | +| `upliftStatuses` | `upliftStatuses` | Optional refinement when `hasUplift=true` | + +Allowed `upliftStatuses`: `pending`, `approved`, `auto_approved`, `rejected` (excludes cancelled/revoked). + +Exemplo: pending uplifts only — `hasUplift=true&upliftStatuses=pending`. ### `datePreset` diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx new file mode 100644 index 00000000..06e2104e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section.tsx @@ -0,0 +1,50 @@ +import type { Dispatch, SetStateAction } from "react"; +import { CalendarDays } from "lucide-react"; +import { AdvFilterPill } from "./adv-filter-pill"; +import { AdvSection } from "./adv-section"; +import { DateField } from "@/components/ui/date-input"; +import { + DATE_RANGE_OPTIONS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils"; + +type AdvDateRangeSectionProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; +}; + +export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) { + return ( + +
    + {DATE_RANGE_OPTIONS.map((option) => ( + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + /> + ))} +
    + {draft.dateRange === "custom" && ( +
    + setDraft((prev) => ({ ...prev, customFrom: value }))} + /> + setDraft((prev) => ({ ...prev, customTo: value }))} + /> +
    + )} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 9bfed9a1..88ba6ca3 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from "react"; import { - CalendarDays, + AlertTriangle, CheckCircle2, FileText, Flame, @@ -10,24 +10,24 @@ import { UserX, Users, } from "lucide-react"; -import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvDateRangeSection } from "./adv-date-range-section"; import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvParityFilterSections } from "./adv-parity-filter-sections"; import { AdvSection } from "./adv-section"; +import { AdvSeverityList } from "./adv-severity-list"; import { AdvTypeGrid } from "./adv-type-grid"; -import { DateField } from "@/components/ui/date-input"; import { SelectAllAction } from "@/components/ui/select-all-action"; import { ALL_WIZARD_STATUSES, ASSIGNEE_FILTER_UNASSIGNED, COMPLETION_DOC_OPTIONS, - DATE_RANGE_OPTIONS, DOC_FILTER_LABELS, type AdvancedFilterSetKey, type WorkOrderAdvancedFilters, } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; -import { isDefaultDateRange } from "@/domain/work-orders/utils/advanced-filters-utils"; type AdvancedSearchFilterSectionsProps = { draft: WorkOrderAdvancedFilters; @@ -61,36 +61,7 @@ export function AdvancedSearchFilterSections({ return (
    - -
    - {DATE_RANGE_OPTIONS.map((option) => ( - setDraft((prev) => ({ ...prev, dateRange: option.key }))} - /> - ))} -
    - {draft.dateRange === "custom" && ( -
    - setDraft((prev) => ({ ...prev, customFrom: value }))} - /> - setDraft((prev) => ({ ...prev, customTo: value }))} - /> -
    - )} -
    + + + + + + + ({ value: pmType, label: pmType }))} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx new file mode 100644 index 00000000..559004c8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-parity-filter-sections.tsx @@ -0,0 +1,146 @@ +import type { Dispatch, SetStateAction } from "react"; +import { Check, DollarSign, Flag, Hash, RefreshCw } from "lucide-react"; +import { Checkbox } from "@mui/material"; +import { AdvDocPill, AdvFilterPill } from "./adv-filter-pill"; +import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvSection } from "./adv-section"; +import { FLAG_COLORS } from "@/domain/work-orders/flag-colors"; +import { + type AdvancedFilterSetKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + UPLIFT_FILTER_LABELS, + UPLIFT_FILTER_STATUSES, + type UpliftFilterStatus, +} from "@/domain/work-orders/types/work-order-uplift-filter"; + +type AdvParityFilterSectionsProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; + toggleIn: (key: AdvancedFilterSetKey, value: string) => void; + setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void; +}; + +function indicatorCount(draft: WorkOrderAdvancedFilters): number { + return (draft.rescheduled ? 1 : 0) + (draft.carriedOver ? 1 : 0) + (draft.addOn ? 1 : 0); +} + +function FlagColorDot({ value }: { value: string }) { + return ( + + ); +} + +export function AdvParityFilterSections({ + draft, + setDraft, + toggleIn, + setMany, +}: AdvParityFilterSectionsProps) { + const toggleUpliftStatus = (status: UpliftFilterStatus) => { + setDraft((prev) => { + const next = new Set(prev.upliftStatuses); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + return { ...prev, upliftStatuses: next }; + }); + }; + + return ( + <> + +
    + setDraft((prev) => ({ ...prev, rescheduled: !prev.rescheduled }))} + className="inline-flex items-center gap-1.5" + /> + setDraft((prev) => ({ ...prev, carriedOver: !prev.carriedOver }))} + className="inline-flex items-center gap-1.5" + /> + setDraft((prev) => ({ ...prev, addOn: !prev.addOn }))} + className="inline-flex items-center gap-1.5" + /> +
    +
    + + + ({ value: color.value, label: color.name }))} + selected={draft.flagColors} + onToggle={(value) => toggleIn("flagColors", value)} + onBulkToggle={(values, select) => setMany("flagColors", values, select)} + placeholder="Search flag colour…" + renderLeft={(value) => } + /> + + + + + + + + + {Boolean(draft.hasUplift) && ( +
    + {UPLIFT_FILTER_STATUSES.map((status) => ( + toggleUpliftStatus(status)} + /> + ))} +
    + )} +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx new file mode 100644 index 00000000..05e6d9c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-severity-list.tsx @@ -0,0 +1,84 @@ +import type { Dispatch, SetStateAction } from "react"; +import { Check } from "lucide-react"; +import { SelectAllAction } from "@/components/ui/select-all-action"; +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + WORK_ORDER_SEVERITIES, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; + +type AdvSeverityListProps = { + draft: WorkOrderAdvancedFilters; + setDraft: Dispatch>; +}; + +export function AdvSeverityList({ draft, setDraft }: AdvSeverityListProps) { + const toggleLevel = (level: WorkOrderSeverity) => { + setDraft((prev) => { + const next = new Set(prev.severities); + if (next.has(level)) { + next.delete(level); + } else { + next.add(level); + } + return { ...prev, severities: next }; + }); + }; + + return ( + <> +
    + 0} + onClick={() => + setDraft((prev) => ({ + ...prev, + severities: + prev.severities.size > 0 + ? new Set() + : new Set(WORK_ORDER_SEVERITIES), + })) + } + /> +
    +
    + {WORK_ORDER_SEVERITIES.map((level) => { + const meta = SEVERITY_LEVELS[level]; + const color = SEVERITY_TOKEN_COLOR[meta.token]; + const selected = draft.severities.has(level); + return ( + + ); + })} +
    + + ); +} diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index a01eed47..ac8dc872 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -1,4 +1,6 @@ import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list"; +import type { WorkOrderSeverity } from "./work-order-severity"; +import type { UpliftFilterStatus } from "./work-order-uplift-filter"; import { ALL_WIZARD_STATUSES, WO_TYPE_META, @@ -26,11 +28,19 @@ export interface WorkOrderAdvancedFilters { customTo: string; sites: Set; types: Set; + severities: Set; dispatchers: Set; statuses: Set; + rescheduled: boolean; + carriedOver: boolean; + addOn: boolean; pmTypes: Set; vendorTechs: Set; docs: Set; + flagColors: Set; + internalOnly: boolean; + hasUplift: boolean; + upliftStatuses: Set; } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { @@ -39,11 +49,19 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { customTo: "", sites: new Set(), types: new Set(), + severities: new Set(), dispatchers: new Set(), statuses: new Set(), + rescheduled: false, + carriedOver: false, + addOn: false, pmTypes: new Set(), vendorTechs: new Set(), docs: new Set(), + flagColors: new Set(), + internalOnly: false, + hasUplift: false, + upliftStatuses: new Set(), }; export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ @@ -71,4 +89,11 @@ export const WO_TYPE_DESCRIPTIONS: Record = Object.fromEntries( export { ALL_WIZARD_STATUSES, WO_TYPES }; export type AdvancedFilterSetKey = - "sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs"; + | "sites" + | "types" + | "dispatchers" + | "statuses" + | "pmTypes" + | "vendorTechs" + | "docs" + | "flagColors"; diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 021fe1c0..bce4cac1 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -234,6 +234,14 @@ export interface WorkOrderBoardSearchQueryParams { pmTypes?: string[]; vendorIds?: number[]; docStatuses?: number[]; + severities?: number[]; + rescheduled?: boolean; + carriedOver?: boolean; + addOn?: boolean; + flagColors?: string[]; + internalOnly?: boolean; + hasUplift?: boolean; + upliftStatuses?: string[]; myWorkOrders?: boolean; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortDir?: "asc" | "desc" | string; diff --git a/src/domain/work-orders/types/work-order-severity.ts b/src/domain/work-orders/types/work-order-severity.ts new file mode 100644 index 00000000..94c3963e --- /dev/null +++ b/src/domain/work-orders/types/work-order-severity.ts @@ -0,0 +1,75 @@ +export type WorkOrderSeverity = 1 | 2 | 3 | 4 | 5; + +export const WORK_ORDER_SEVERITIES: readonly WorkOrderSeverity[] = [1, 2, 3, 4, 5]; + +/** Types that require an APM severity transcription. */ +export const SEVERITY_TYPES = ["Emergency", "Reactive"] as const; + +export type SeverityWoType = (typeof SEVERITY_TYPES)[number]; + +export type SeverityToken = "critical" | "warning" | "safe"; + +export const SEVERITY_LEVELS: Record< + WorkOrderSeverity, + { + label: string; + description: string; + deadline: string; + afterHours: string; + token: SeverityToken; + } +> = { + 1: { + label: "SEV 1", + description: "Business Critical Function Down", + deadline: "Accept: 15 min · Respond: 2 hrs", + afterHours: "Yes", + token: "critical", + }, + 2: { + label: "SEV 2", + description: "Business Critical Function Impaired", + deadline: "Accept: 30 min · Respond: 4 hrs", + afterHours: "Yes", + token: "critical", + }, + 3: { + label: "SEV 3", + description: "Group Productivity Impaired", + deadline: "Accept: 2 hrs · Respond: 8 hrs (next day if after hours)", + afterHours: "Approval required", + token: "warning", + }, + 4: { + label: "SEV 4", + description: "Individual Productivity Impaired", + deadline: "Accept: 12 hrs · Respond: 24 hrs (next day)", + afterHours: "No", + token: "safe", + }, + 5: { + label: "SEV 5", + description: "Productivity not immediately affected", + deadline: "Accept: 12 hrs · Respond: 72 hrs", + afterHours: "No", + token: "safe", + }, +}; + +export const SEVERITY_TOKEN_COLOR: Record = { + critical: "var(--sla-critical, #DC2626)", + warning: "var(--sla-warning, #D97706)", + safe: "var(--sla-safe, #16A34A)", +}; + +export function typeNeedsSeverity(type: string): boolean { + return (SEVERITY_TYPES as readonly string[]).includes(type); +} + +export function parseWorkOrderSeverity(value: unknown): WorkOrderSeverity | null { + const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) { + return n; + } + return null; +} diff --git a/src/domain/work-orders/types/work-order-uplift-filter.ts b/src/domain/work-orders/types/work-order-uplift-filter.ts new file mode 100644 index 00000000..a26ca0d1 --- /dev/null +++ b/src/domain/work-orders/types/work-order-uplift-filter.ts @@ -0,0 +1,16 @@ +/** Uplift statuses exposed in advanced search (excludes cancelled/revoked). */ +export type UpliftFilterStatus = "pending" | "approved" | "auto_approved" | "rejected"; + +export const UPLIFT_FILTER_STATUSES: UpliftFilterStatus[] = [ + "pending", + "approved", + "auto_approved", + "rejected", +]; + +export const UPLIFT_FILTER_LABELS: Record = { + pending: "Pending", + approved: "Approved", + auto_approved: "Auto-approved", + rejected: "Rejected", +}; diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts new file mode 100644 index 00000000..8150c9d2 --- /dev/null +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -0,0 +1,283 @@ +import { FLAG_COLORS } from "@/domain/work-orders/flag-colors"; +import { + ASSIGNEE_FILTER_UNASSIGNED, + DATE_RANGE_OPTIONS, + DOC_FILTER_LABELS, + type CompletionDocFilter, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + SEVERITY_LEVELS, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; +import { + UPLIFT_FILTER_LABELS, + type UpliftFilterStatus, +} from "@/domain/work-orders/types/work-order-uplift-filter"; +import { MONTH_NAMES } from "@/domain/work-orders/utils/wizard-date-utils"; + +export interface AdvancedFilterChip { + key: string; + group: string; + value?: string; + count?: number; + accent?: boolean; + clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; +} + +type ChipBuilderContext = { + siteNameOf: (id: string) => string; + dispatcherNameOf: (id: string) => string; +}; + +type SetChipInput = { + chips: AdvancedFilterChip[]; + key: string; + group: string; + set: Set; + nameOf: (value: string) => string; + clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; +}; + +function pushSetChip(input: SetChipInput): void { + const { chips, key, group, set, nameOf, clear } = input; + if (!set.size) return; + if (set.size <= 2) { + chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear }); + return; + } + chips.push({ key, group, count: set.size, clear }); +} + +function fmtChipDate(iso: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ""; + const [, m, d] = iso.split("-").map(Number); + return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`; +} + +function severityLabel(level: WorkOrderSeverity): string { + return SEVERITY_LEVELS[level].label; +} + +export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { + if (filters.dateRange === "this-week") return; + const value = + filters.dateRange === "custom" + ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` + : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom"); + chips.push({ + key: "date", + group: "Date", + value, + clear: (current) => ({ + ...current, + dateRange: "this-week", + customFrom: "", + customTo: "", + }), + }); +} + +export function appendSetChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, + ctx: ChipBuilderContext, +) { + pushSetChip({ + chips, + key: "sites", + group: "Site", + set: filters.sites, + nameOf: ctx.siteNameOf, + clear: (current) => ({ ...current, sites: new Set() }), + }); + + pushSetChip({ + chips, + key: "types", + group: "Type of WO", + set: filters.types as Set, + nameOf: (v) => v, + clear: (current) => ({ ...current, types: new Set() }), + }); +} + +export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { + if (!filters.severities?.size) return; + const clearSeverity = (current: WorkOrderAdvancedFilters) => ({ + ...current, + severities: new Set(), + }); + if (filters.severities.size <= 2) { + chips.push({ + key: "severity", + group: "Severity", + value: [...filters.severities].map(severityLabel).join(", "), + clear: clearSeverity, + }); + return; + } + chips.push({ + key: "severity", + group: "Severity", + count: filters.severities.size, + clear: clearSeverity, + }); +} + +export function appendDispatcherChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, + ctx: ChipBuilderContext, +) { + const realDispatchers = new Set( + [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED), + ); + pushSetChip({ + chips, + key: "disp", + group: "Assigned to", + set: realDispatchers, + nameOf: ctx.dispatcherNameOf, + clear: (current) => ({ + ...current, + dispatchers: new Set( + [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED), + ), + }), + }); + + if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) { + chips.push({ + key: "disp-unassigned", + group: "Assigned to", + value: "Unassigned", + accent: true, + clear: (current) => { + const next = new Set(current.dispatchers); + next.delete(ASSIGNEE_FILTER_UNASSIGNED); + return { ...current, dispatchers: next }; + }, + }); + } +} + +export function appendStatusPmVendorDocChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + pushSetChip({ + chips, + key: "status", + group: "Status", + set: filters.statuses as Set, + nameOf: (v) => v, + clear: (current) => ({ ...current, statuses: new Set() }), + }); + + pushSetChip({ + chips, + key: "pm", + group: "PM type", + set: filters.pmTypes, + nameOf: (v) => v, + clear: (current) => ({ ...current, pmTypes: new Set() }), + }); + + pushSetChip({ + chips, + key: "vendor", + group: "Vendor / tech", + set: filters.vendorTechs, + nameOf: (v) => v, + clear: (current) => ({ ...current, vendorTechs: new Set() }), + }); + + pushSetChip({ + chips, + key: "doc", + group: "Comp doc", + set: filters.docs as Set, + nameOf: (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v, + clear: (current) => ({ ...current, docs: new Set() }), + }); +} + +export function appendIndicatorChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + if (filters.rescheduled) { + chips.push({ + key: "rescheduled", + group: "Indicator", + value: "Rescheduled", + clear: (current) => ({ ...current, rescheduled: false }), + }); + } + if (filters.carriedOver) { + chips.push({ + key: "carriedover", + group: "Indicator", + value: "Carried Over", + clear: (current) => ({ ...current, carriedOver: false }), + }); + } + if (filters.addOn) { + chips.push({ + key: "addon", + group: "Indicator", + value: "Add-On", + clear: (current) => ({ ...current, addOn: false }), + }); + } +} + +export function appendFlagAndInternalChips( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + pushSetChip({ + chips, + key: "flags", + group: "Flag", + set: filters.flagColors, + nameOf: (v) => FLAG_COLORS.find((c) => c.value === v)?.name ?? v, + clear: (current) => ({ ...current, flagColors: new Set() }), + }); + + if (filters.internalOnly) { + chips.push({ + key: "internal", + group: "WO#", + value: "Internal (SH)", + clear: (current) => ({ ...current, internalOnly: false }), + }); + } +} + +export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { + if (!filters.hasUplift) return; + const clearUplift = (current: WorkOrderAdvancedFilters) => ({ + ...current, + hasUplift: false, + upliftStatuses: new Set(), + }); + if (filters.upliftStatuses.size) { + pushSetChip({ + chips, + key: "uplift", + group: "Uplift", + set: filters.upliftStatuses as Set, + nameOf: (v) => UPLIFT_FILTER_LABELS[v as UpliftFilterStatus] ?? v, + clear: clearUplift, + }); + return; + } + chips.push({ + key: "uplift", + group: "Uplift", + value: "Has uplift", + clear: clearUplift, + }); +} diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 13c5aa1b..112204c8 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -44,6 +44,18 @@ const matchesVendorTech: AdvancedFilterPredicate = (row, filters) => const matchesDoc: AdvancedFilterPredicate = (row, filters) => !filters.docs.size || filters.docs.has(row.docStatus as never); +const matchesRescheduled: AdvancedFilterPredicate = (row, filters) => + !filters.rescheduled || row.rescheduleCount >= 2; + +const matchesCarriedOver: AdvancedFilterPredicate = (row, filters) => + !filters.carriedOver || row.carriedOver >= 2; + +const matchesInternalOnly: AdvancedFilterPredicate = (row, filters) => + !filters.internalOnly || row.woNumber.toUpperCase().startsWith("SH"); + +const matchesFlagColors: AdvancedFilterPredicate = (row, filters) => + !filters.flagColors.size || (!!row.flagColor && filters.flagColors.has(row.flagColor)); + /** One predicate per filter dimension; a row matches when every predicate is true. */ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesDateRange, @@ -54,4 +66,8 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesPmType, matchesVendorTech, matchesDoc, + matchesRescheduled, + matchesCarriedOver, + matchesInternalOnly, + matchesFlagColors, ]; diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index ad132df4..8266a7b0 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -1,59 +1,60 @@ import { - ASSIGNEE_FILTER_UNASSIGNED, - DATE_RANGE_OPTIONS, - DOC_FILTER_LABELS, EMPTY_ADVANCED_FILTERS, - type CompletionDocFilter, type DateRangeKey, type WorkOrderAdvancedFilters, } from "@/domain/work-orders/types/work-order-advanced-filters"; import { - MONTH_NAMES, - addDaysIso, - mondayOf, - pad2, - todayIso, -} from "@/domain/work-orders/utils/wizard-date-utils"; + appendDateChip, + appendDispatcherChips, + appendFlagAndInternalChips, + appendIndicatorChips, + appendSetChips, + appendSeverityChip, + appendStatusPmVendorDocChips, + appendUpliftChip, + type AdvancedFilterChip, +} from "@/domain/work-orders/utils/advanced-filter-chip-builders"; +import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; -export interface AdvancedFilterChip { - key: string; - group: string; - value?: string; - count?: number; - accent?: boolean; - clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters; -} +export type { AdvancedFilterChip } from "@/domain/work-orders/utils/advanced-filter-chip-builders"; function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters { return { + ...EMPTY_ADVANCED_FILTERS, ...filters, sites: new Set(filters.sites), types: new Set(filters.types), + severities: new Set(filters.severities ?? []), dispatchers: new Set(filters.dispatchers), statuses: new Set(filters.statuses), pmTypes: new Set(filters.pmTypes), vendorTechs: new Set(filters.vendorTechs), docs: new Set(filters.docs), + flagColors: new Set(filters.flagColors ?? []), + upliftStatuses: new Set(filters.upliftStatuses ?? []), }; } -export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { - let count = 0; - if (filters.dateRange !== "this-week") count += 1; - if (filters.sites.size) count += 1; - if (filters.types.size) count += 1; - if (filters.dispatchers.size) count += 1; - if (filters.statuses.size) count += 1; - if (filters.pmTypes.size) count += 1; - if (filters.vendorTechs.size) count += 1; - if (filters.docs.size) count += 1; - return count; -} +const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [ + filters.dateRange !== "this-week", + Boolean(filters.sites?.size), + Boolean(filters.types?.size), + Boolean(filters.severities?.size), + Boolean(filters.dispatchers?.size), + Boolean(filters.statuses?.size), + filters.rescheduled, + filters.carriedOver, + filters.addOn, + Boolean(filters.pmTypes?.size), + Boolean(filters.vendorTechs?.size), + Boolean(filters.docs?.size), + Boolean(filters.flagColors?.size), + filters.internalOnly, + filters.hasUplift, +]; -function fmtChipDate(iso: string): string { - if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ""; - const [, m, d] = iso.split("-").map(Number); - return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`; +export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { + return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length; } function monthStart(y: number, m: number): string { @@ -91,22 +92,6 @@ export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string } } -function setChip( - chips: AdvancedFilterChip[], - key: string, - group: string, - set: Set, - nameOf: (value: string) => string, - clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters, -) { - if (!set.size) return; - if (set.size <= 2) { - chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear }); - return; - } - chips.push({ key, group, count: set.size, clear }); -} - export function buildAdvancedFilterChips( filters: WorkOrderAdvancedFilters, options?: { @@ -115,100 +100,19 @@ export function buildAdvancedFilterChips( }, ): AdvancedFilterChip[] { const chips: AdvancedFilterChip[] = []; - const siteNameOf = options?.siteNameOf ?? ((v) => v); - const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v); + const ctx = { + siteNameOf: options?.siteNameOf ?? ((v: string) => v), + dispatcherNameOf: options?.dispatcherNameOf ?? ((v: string) => v), + }; - if (filters.dateRange !== "this-week") { - const value = - filters.dateRange === "custom" - ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` - : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom"); - chips.push({ - key: "date", - group: "Date", - value, - clear: (current) => ({ - ...current, - dateRange: "this-week", - customFrom: "", - customTo: "", - }), - }); - } - - setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({ - ...current, - sites: new Set(), - })); - - setChip( - chips, - "types", - "Type of WO", - filters.types as Set, - (v) => v, - (current) => ({ ...current, types: new Set() }), - ); - - const realDispatchers = new Set( - [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED), - ); - setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({ - ...current, - dispatchers: new Set( - [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED), - ), - })); - - if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) { - chips.push({ - key: "disp-unassigned", - group: "Assigned to", - value: "Unassigned", - accent: true, - clear: (current) => { - const next = new Set(current.dispatchers); - next.delete(ASSIGNEE_FILTER_UNASSIGNED); - return { ...current, dispatchers: next }; - }, - }); - } - - setChip( - chips, - "status", - "Status", - filters.statuses as Set, - (v) => v, - (current) => ({ ...current, statuses: new Set() }), - ); - - setChip( - chips, - "pm", - "PM type", - filters.pmTypes, - (v) => v, - (current) => ({ ...current, pmTypes: new Set() }), - ); - - setChip( - chips, - "vendor", - "Vendor / tech", - filters.vendorTechs, - (v) => v, - (current) => ({ ...current, vendorTechs: new Set() }), - ); - - setChip( - chips, - "doc", - "Comp doc", - filters.docs as Set, - (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v, - (current) => ({ ...current, docs: new Set() }), - ); + appendDateChip(chips, filters); + appendSetChips(chips, filters, ctx); + appendSeverityChip(chips, filters); + appendDispatcherChips(chips, filters, ctx); + appendStatusPmVendorDocChips(chips, filters); + appendIndicatorChips(chips, filters); + appendFlagAndInternalChips(chips, filters); + appendUpliftChip(chips, filters); return chips; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 6f4824fe..ba8d6701 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -77,6 +77,47 @@ function mapDocStatuses(docs: Iterable): number[] | undefin return list.length > 0 ? list : undefined; } +function resolveVendorIds( + vendorTechs: Iterable, + vendorIdsByCompanyName?: Map, +): number[] | undefined { + if (!vendorIdsByCompanyName) return undefined; + const vendorIds: number[] = []; + for (const name of vendorTechs) { + const id = vendorIdsByCompanyName.get(name); + if (typeof id === "number") { + vendorIds.push(id); + } + } + return vendorIds.length > 0 ? vendorIds : undefined; +} + +function mapSearchFacetParams( + filters: WorkOrderAdvancedFilters, +): Pick< + WorkOrderBoardSearchQueryParams, + | "severities" + | "rescheduled" + | "carriedOver" + | "addOn" + | "flagColors" + | "internalOnly" + | "hasUplift" + | "upliftStatuses" +> { + return { + severities: filters.severities?.size ? [...filters.severities] : undefined, + rescheduled: filters.rescheduled || undefined, + carriedOver: filters.carriedOver || undefined, + addOn: filters.addOn || undefined, + flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined, + internalOnly: filters.internalOnly || undefined, + hasUplift: filters.hasUplift || undefined, + upliftStatuses: + filters.hasUplift && filters.upliftStatuses?.size ? [...filters.upliftStatuses] : undefined, + }; +} + /** Build GET /board query from weekly filter bar. */ export function toBoardQueryParams( bar: WorkOrderBarFilters, @@ -113,23 +154,14 @@ export function toBoardSearchQueryParams(input: { BOARD_SEARCH_MAX_PAGE_SIZE, ); - const vendorIds: number[] = []; - if (input.vendorIdsByCompanyName && filters.vendorTechs.size > 0) { - for (const name of filters.vendorTechs) { - const id = input.vendorIdsByCompanyName.get(name); - if (typeof id === "number") { - vendorIds.push(id); - } - } - } - const typeFilters = mapWoTypeFilters(filters.types); + const trimmedSearch = input.search?.trim(); + const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; const params: WorkOrderBoardSearchQueryParams = { page: input.page ?? 0, pageSize, - search: - input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined, + search, datePreset, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, types: typeFilters.types, @@ -137,8 +169,9 @@ export function toBoardSearchQueryParams(input: { dispatchers: mapDispatchers(filters.dispatchers), statuses: mapStatuses(filters.statuses), pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined, - vendorIds: vendorIds.length > 0 ? vendorIds : undefined, + vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName), docStatuses: mapDocStatuses(filters.docs), + ...mapSearchFacetParams(filters), sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", }; diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx new file mode 100644 index 00000000..8f9fc28d --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-advanced-filters-banner-count.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; + +describe("WorkOrdersAdvancedFiltersBanner count semantics", () => { + it("labels the banner with search totalCount, not the current page length", () => { + render( + undefined} + onClearAll={() => undefined} + />, + ); + + expect(screen.getByText("150 work orders")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index ab94481a..74d58e8d 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -2,6 +2,10 @@ import { fireEvent, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import WorkOrdersListPage from "@/app/(protected)/workorders/index"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + BOARD_SEARCH_MAX_PAGE_SIZE, + toBoardSearchQueryParams, +} from "@/domain/work-orders/utils/board-query-params"; import { renderWithProviders } from "@/test/test-utils"; const setAdvOpen = vi.fn(); @@ -151,4 +155,38 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { ); expect(clearAdvancedFilters).toHaveBeenCalled(); }); + + it("applies Severity and Has uplift through the public Advanced Filters sheet", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /Severity/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /SEV 1/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /^Uplift/i })); + fireEvent.click(within(sheet).getByLabelText(/Has uplift/i)); + fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + + expect(applyAdvancedFilters).toHaveBeenCalled(); + const applied = applyAdvancedFilters.mock.calls.at(-1)?.[0] as typeof EMPTY_ADVANCED_FILTERS; + expect(applied.severities.has(1)).toBe(true); + expect(applied.hasUplift).toBe(true); + expect(applied.upliftStatuses.has("pending")).toBe(true); + + const params = toBoardSearchQueryParams({ filters: applied }); + expect(params.page).toBe(0); + expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE); + expect(params.severities).toEqual([1]); + expect(params.hasUplift).toBe(true); + expect(params.upliftStatuses).toEqual(["pending"]); + }); }); diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts index f96d19ee..c4f351fa 100644 --- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -100,4 +100,46 @@ describe("clearAdvancedFilterGroup", () => { const next = clearAdvancedFilterGroup(withSites("1"), "sites"); expect(next).toBeNull(); }); + + it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + severities: new Set([1, 2] as const), + rescheduled: true, + flagColors: new Set(["#EF4444"]), + internalOnly: true, + hasUplift: true, + upliftStatuses: new Set(["pending" as const]), + }; + + const chips = buildAdvancedFilterChips(filters); + expect(chips.some((c) => c.key === "severity" && c.value === "SEV 1, SEV 2")).toBe(true); + expect(chips.some((c) => c.key === "rescheduled" && c.value === "Rescheduled")).toBe(true); + expect(chips.some((c) => c.key === "flags" && c.value === "Red")).toBe(true); + expect(chips.some((c) => c.key === "internal" && c.value === "Internal (SH)")).toBe(true); + expect(chips.some((c) => c.key === "uplift" && c.value === "Pending")).toBe(true); + }); + + it("clears uplift chip and drops hasUplift with statuses", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + hasUplift: true, + upliftStatuses: new Set(["pending" as const, "approved" as const]), + }; + + const next = clearAdvancedFilterGroup(filters, "uplift"); + expect(next).toBeNull(); + }); + + it("clears indicator chip without affecting other groups", () => { + const filters = { + ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), + rescheduled: true, + severities: new Set([3] as const), + }; + + const next = clearAdvancedFilterGroup(filters, "rescheduled"); + expect(next?.rescheduled).toBe(false); + expect(next?.severities.has(3)).toBe(true); + }); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 3fd01ec9..31461891 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -147,6 +147,50 @@ describe("toBoardSearchQueryParams", () => { expect(params.pmTypes).toBeUndefined(); expect(params.vendorIds).toBeUndefined(); expect(params.docStatuses).toBeUndefined(); + expect(params.severities).toBeUndefined(); + expect(params.rescheduled).toBeUndefined(); + expect(params.carriedOver).toBeUndefined(); + expect(params.addOn).toBeUndefined(); + expect(params.flagColors).toBeUndefined(); + expect(params.internalOnly).toBeUndefined(); + expect(params.hasUplift).toBeUndefined(); + expect(params.upliftStatuses).toBeUndefined(); + }); + + it("maps severity and uplift facets onto board search params", () => { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + severities: new Set([1, 3, 5]), + rescheduled: true, + carriedOver: true, + addOn: true, + flagColors: new Set(["#EF4444", "#3B82F6"]), + internalOnly: true, + hasUplift: true, + upliftStatuses: new Set(["pending", "approved"]), + }, + }); + expect(params.severities).toEqual([1, 3, 5]); + expect(params.rescheduled).toBe(true); + expect(params.carriedOver).toBe(true); + expect(params.addOn).toBe(true); + expect(params.flagColors).toEqual(["#EF4444", "#3B82F6"]); + expect(params.internalOnly).toBe(true); + expect(params.hasUplift).toBe(true); + expect(params.upliftStatuses).toEqual(["pending", "approved"]); + }); + + it("omits upliftStatuses when hasUplift is false", () => { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + hasUplift: false, + upliftStatuses: new Set(["pending"]), + }, + }); + expect(params.hasUplift).toBeUndefined(); + expect(params.upliftStatuses).toBeUndefined(); }); it("caps pageSize at BOARD_SEARCH_MAX_PAGE_SIZE", () => { From f58541d06a5d50facde3e6912552b687f56979e7 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 11 Aug 2026 15:47:23 -0300 Subject: [PATCH 13/31] fix(work-orders): keep SH-196 slice under maintainability gates Extract EmptyInlineCell so WoTableInlineRowCells stays under the 150-line limit, and add upliftSummary to the unscheduled highlight fixture. --- .../list/table/wo-table-inline-row-cells.tsx | 17 ++++++++++------- .../wo-table-unscheduled-highlight.test.tsx | 1 + 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 66b4d2dc..4ecc9a17 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -28,6 +28,14 @@ type WoTableInlineRowCellsProps = { onPatch: (patch: Partial) => void; }; +function EmptyInlineCell() { + return ( +
    + ); +} + export function WoTableInlineRowCells({ draft, weekDefault, @@ -170,13 +178,8 @@ export function WoTableInlineRowCells({ /> - - - + + ); } diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx index 011c95a6..ee737718 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx @@ -51,6 +51,7 @@ const unscheduledRow: WorkOrderTableRow = { rowVersion: "", dispatchRowVersion: "", flagColor: null, + upliftSummary: { hasUplift: false, pendingCount: 0 }, }; const handlers: WoTableRowHandlers = { From ba4f3ca96d1cd41ad7b5cdd7af66bba8aec1b161 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 11 Aug 2026 15:59:33 -0300 Subject: [PATCH 14/31] fix(work-orders): avoid Array.at in advanced search wiring test Keep the public-interface assertion compatible with the project's TypeScript lib target. --- .../workorders/work-orders-advanced-search-wiring.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 74d58e8d..7e0fbe28 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -177,7 +177,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); expect(applyAdvancedFilters).toHaveBeenCalled(); - const applied = applyAdvancedFilters.mock.calls.at(-1)?.[0] as typeof EMPTY_ADVANCED_FILTERS; + const appliedCalls = applyAdvancedFilters.mock.calls; + const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS; expect(applied.severities.has(1)).toBe(true); expect(applied.hasUplift).toBe(true); expect(applied.upliftStatuses.has("pending")).toBe(true); From 8a221c8819e449919e7ceac2194c66bbdeef9b1a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 12 Aug 2026 09:55:34 -0300 Subject: [PATCH 15/31] fix(work-orders): consume board pendingUpliftCount for SH-188 gate Map the authoritative board/search/detail pendingUpliftCount into the Completed closability gate and align the pending-uplift tooltip with SH-188. --- .../mappers/work-order-table-row-mapper.ts | 28 +++++++++++-------- .../work-orders/types/work-order-board.ts | 7 +++++ .../work-orders/utils/wo-closability.ts | 7 +++-- .../status-cell-pending-uplift.test.tsx | 27 +++++++++++++++++- .../mappers/work-order-mapper.test.ts | 22 +++++++++++++++ 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 3b9fc66b..ab226518 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -177,23 +177,25 @@ function readFlagColor(record: Record): string | null { function readUpliftSummary(item: Record): WorkOrderUpliftSummary | undefined { const nested = asRecord(item.upliftSummary ?? item.UpliftSummary); - const source = Object.keys(nested).length > 0 ? nested : item; + const hasNested = Object.keys(nested).length > 0; + const source = hasNested ? nested : item; - const pendingCount = - readNumber(source, "pendingCount", "PendingCount") ?? - readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? - 0; + // Board/search/detail contract (SH-188): pendingUpliftCount is authoritative when present. + const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount"); + const nestedCount = readNumber(source, "pendingCount", "PendingCount"); + const pendingCount = nestedCount ?? boardCount ?? 0; const hasUplift = readBool(source, "hasUplift", "HasUplift") || readBool(item, "hasUplift", "HasUplift") || pendingCount > 0; - if (!hasUplift && pendingCount === 0) { + // No nested summary and no board count → omit (legacy payloads without the contract field). + if (!hasNested && boardCount == null && !hasUplift) { return undefined; } const summary: WorkOrderUpliftSummary = { - hasUplift, + hasUplift: hasUplift || pendingCount > 0, pendingCount, }; const primaryStatus = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); @@ -207,11 +209,15 @@ function readHasPendingUplift( item: Record, upliftSummary: WorkOrderUpliftSummary | undefined, ): boolean | undefined { - if (readBool(item, "hasPendingUplift", "HasPendingUplift")) { - return true; + if ("hasPendingUplift" in item || "HasPendingUplift" in item) { + return Boolean(item.hasPendingUplift ?? item.HasPendingUplift); } - if ((upliftSummary?.pendingCount ?? 0) > 0) { - return true; + const boardCount = readNumber(item, "pendingUpliftCount", "PendingUpliftCount"); + if (boardCount != null) { + return boardCount > 0; + } + if (upliftSummary != null) { + return upliftSummary.pendingCount > 0; } return undefined; } diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 021fe1c0..fec033b8 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -159,9 +159,16 @@ export interface WorkOrderBoardRowApi { serviceNotes?: string | null; extraServices?: string[] | null; docStatus?: number | null; + /** Work completed date from board/detail contract. */ + completedDate?: string | null; primaryDispatchId?: number | null; rowVersion?: string | null; dispatchRowVersion?: string | null; + /** + * Authoritative pending-uplift count across all dispatches for this WO (SH-188 / BE board contract). + * Drives the Completed closability gate. + */ + pendingUpliftCount?: number; } export interface WorkOrderBoardCountsApi { diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 7259cbf5..388d0e02 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -25,9 +25,9 @@ export type WoClosabilityInput = { export const PENDING_UPLIFT_GAP = "Pending uplift approval"; -/** Design copy when Completed is blocked solely by a pending uplift. */ +/** SH-188 tooltip when Completed is blocked solely by a pending uplift. */ export const UPLIFT_PENDING_COMPLETED_TITLE = - "This work order cannot be completed while an uplift is pending approval."; + "This work order has an uplift pending approval and cannot be completed."; /** Real (non-placeholder) WO number — non-empty and not an SH-prefixed draft id. */ export function isRealWoNumber(woNumber: string): boolean { @@ -83,7 +83,8 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { gaps.push("Service"); } - // Only an actually pending uplift blocks completion; missing uplift fields mean no pending evidence. + // Authoritative pending uplift (board pendingUpliftCount / hasPendingUplift) blocks completion. + // Missing uplift fields mean no pending evidence until the board contract supplies the count. if (hasPendingUplift(row)) { gaps.push(PENDING_UPLIFT_GAP); } diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index 998b2680..5d610390 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -71,13 +71,38 @@ describe("StatusCell pending uplift closability", () => { expect(completed).toBeDisabled(); expect(completed).toHaveAttribute( "title", - "This work order cannot be completed while an uplift is pending approval.", + "This work order has an uplift pending approval and cannot be completed.", ); fireEvent.click(completed); expect(onChangeStatus).not.toHaveBeenCalled(); }); + it("disables Completed with tooltip when board pendingUpliftCount maps to pending", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute( + "title", + "This work order has an uplift pending approval and cannot be completed.", + ); + }); + it("allows Completed when no pending uplift", () => { const onChangeStatus = vi.fn(); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index 1b3baa4e..2a33a51c 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -154,6 +154,28 @@ describe("mapWorkOrderTableRow (board DTO)", () => { expect(row.hasPendingUplift).toBe(true); }); + it("maps board pendingUpliftCount into hasPendingUplift (SH-188 contract)", () => { + const pending = mapWorkOrderTableRow({ + id: 305, + pendingUpliftCount: 1, + }); + expect(pending.hasPendingUplift).toBe(true); + expect(pending.upliftSummary).toEqual({ hasUplift: true, pendingCount: 1 }); + + const clear = mapWorkOrderTableRow({ + id: 306, + PendingUpliftCount: 0, + }); + expect(clear.hasPendingUplift).toBe(false); + expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 }); + }); + + it("leaves hasPendingUplift undefined when board count is absent", () => { + const row = mapWorkOrderTableRow({ id: 307 }); + expect(row.hasPendingUplift).toBeUndefined(); + expect(row.upliftSummary).toBeUndefined(); + }); + it("maps hasPendingUplift from top-level API flag", () => { const row = mapWorkOrderTableRow({ id: 304, From 1835a5939a01847e7d721f5cb399f1b08c92f694 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 15:21:16 -0300 Subject: [PATCH 16/31] fix(work-orders): dedupe completedDate and gate Completed on technician (SH-188) Refresh onto current dev and resolve exact-head review blockers: - Remove duplicate WorkOrderBoardRowApi.completedDate after merge with dev - Add SH-114 Technician closability gate alongside company/vendor check - Update wo-closability regressions for missing technician --- src/domain/work-orders/types/work-order-board.ts | 2 -- src/domain/work-orders/utils/wo-closability.ts | 5 ++++- src/test/domain/work-orders/utils/wo-closability.test.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index b9ad2110..9f86b153 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -162,8 +162,6 @@ export interface WorkOrderBoardRowApi { serviceNotes?: string | null; extraServices?: string[] | null; docStatus?: number | null; - /** Work completed date from board/detail contract. */ - completedDate?: string | null; primaryDispatchId?: number | null; rowVersion?: string | null; dispatchRowVersion?: string | null; diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 388d0e02..f8ed2972 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -10,7 +10,7 @@ export type WoClosabilityInput = { dispatcherName: string; /** Vendor/company assignment on the board row. */ company: string; - /** Technician name is informational and does not satisfy the vendor requirement. */ + /** Technician assigned to the work order. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; @@ -70,6 +70,9 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!hasDispatcher(row)) { gaps.push("Dispatcher"); } + if (!row.tech.trim()) { + gaps.push("Technician"); + } if (!row.company.trim()) { gaps.push("Company / vendor"); } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index ea06ad4d..228f3dbe 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -60,8 +60,8 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); - it("accepts a company without a technician", () => { - expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual([]); + it("blocks Completed when technician is missing", () => { + expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual(["Technician"]); }); it("does not let a technician satisfy a missing company requirement", () => { @@ -86,6 +86,7 @@ describe("getClosabilityGaps", () => { ), ).toEqual([ "Dispatcher", + "Technician", "Company / vendor", "Completed Date", "WO number", From 93034f5da2c65f75eb0a7177a71f650fd1249917 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Thu, 13 Aug 2026 16:49:50 -0300 Subject: [PATCH 17/31] fix(work-orders): implement SH-165 Unassigned pinned queue Replace Unscheduled-focused changes with assignment-based board partition, accessible disclosure, duplicate prevention, and inline unassign support. --- docs/work-orders/board-search-api.md | 9 - .../list/table/cells/assigned-to-cell.tsx | 12 ++ .../_components/list/table/wo-table-body.tsx | 24 ++- .../wo-table-collapsible-section-header.tsx | 119 +++++++++++++ .../list/table/wo-table-inline-row.tsx | 7 +- .../list/table/wo-table-section-header.tsx | 115 ++++-------- .../_hooks/use-work-orders-table-data.ts | 22 ++- .../use-work-orders-table-local-state.ts | 2 + .../_hooks/use-work-orders-table-ui-state.ts | 8 +- .../work-orders/types/work-order-table-row.ts | 1 + src/domain/work-orders/types/work-order.ts | 1 + .../work-orders/utils/table-filter-utils.ts | 40 ++++- ...tsx => wo-table-unassigned-queue.test.tsx} | 92 +++++++--- .../board-unscheduled-design-parity.test.ts | 165 ------------------ .../utils/table-filter-utils.test.ts | 84 ++++++--- 15 files changed, 396 insertions(+), 305 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-collapsible-section-header.tsx rename src/test/app/(protected)/workorders/{wo-table-body-unscheduled.test.tsx => wo-table-unassigned-queue.test.tsx} (55%) delete mode 100644 src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index e476f53c..fde9438b 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -118,15 +118,6 @@ Pending (`No`)=2, Uploaded (`Yes`)=1, N/N (`NN`)=3. } ``` -#### Buckets - -| Array | Inclusion rule | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `scheduled` | WOs with `scheduledDate` inside `weekStart`–`weekEnd` | -| `unscheduled` | **All** WOs without `scheduledDate` (cross-week). Visible for every week request; `targetWeek` is not a week filter — Schedule-cell marker only | - -The FE concatenates `unscheduled + scheduled` into `items` and renders `unscheduled` in the pinned Unscheduled section above day groups. Dated WOs without a dispatcher stay in `scheduled` / day groups (Assigned To highlight), not in `unscheduled`. - ### `/board/search` ```json diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index 92557ea2..42a1bf93 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -75,6 +75,18 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig />
    + {filtered.map((d) => (
    ; } + const showUnassigned = + tableData.unassigned.length > 0 || tableData.draftDayKey === UNASSIGNED_DAY_KEY; const showUnscheduled = tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; return ( + {showUnassigned && ( + <> + tableData.setUnassignedOpen((v) => !v)} + /> + {tableData.unassignedOpen && ( + <> + {renderInlineRow(UNASSIGNED_DAY_KEY)} + {tableData.unassigned.map((row) => renderRow(row))} + + )} + + )} {showUnscheduled && ( <> = { + unassigned: { + background: "rgba(99, 102, 241, 0.08)", + borderTop: "1px solid #C7D2FE", + borderBottom: "1px solid #C7D2FE", + accentColor: "var(--primary)", + boxShadow: "inset 3px 0 0 var(--primary)", + labelFontFamily: "var(--font-mono)", + labelLetterSpacing: "0.08em", + }, + unscheduled: { + background: "rgba(217, 119, 6, 0.08)", + borderTop: "1px solid #FDE68A", + borderBottom: "1px solid #FDE68A", + accentColor: "var(--warning)", + boxShadow: "inset 3px 0 0 var(--warning)", + labelFontFamily: "var(--font-mono)", + labelLetterSpacing: "0.08em", + }, +}; + +type WoTableCollapsibleSectionHeaderProps = { + tone: "unassigned" | "unscheduled"; + icon: LucideIcon; + label: string; + count: number; + collapsed?: boolean; + onToggleCollapse: () => void; + onAddWo?: () => void; +}; + +export function WoTableCollapsibleSectionHeader({ + tone, + icon: Icon, + label, + count, + collapsed, + onToggleCollapse, + onAddWo, +}: WoTableCollapsibleSectionHeaderProps) { + const style = SECTION_STYLES[tone]; + + return ( + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 60bc03f4..76c5deb6 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -8,7 +8,10 @@ import type { import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; -import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; +import { + UNASSIGNED_DAY_KEY, + UNSCHEDULED_DAY_KEY, +} from "@/domain/work-orders/types/work-order-table-row"; import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; import { WoTableInlineRowCells } from "./wo-table-inline-row-cells"; @@ -65,7 +68,7 @@ export function WoTableInlineRow({ onCancel, }: WoTableInlineRowProps) { - const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; + const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY; const canSave = !saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx index a27feca7..a730c7f5 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-section-header.tsx @@ -1,14 +1,15 @@ -import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react"; +import { AlertTriangle, Plus, Users } from "lucide-react"; import { WO_TABLE_COLS } from "./wo-table-cols"; +import { WoTableCollapsibleSectionHeader } from "./wo-table-collapsible-section-header"; type WoTableSectionHeaderProps = { - variant: "unscheduled" | "day"; + variant: "unassigned" | "unscheduled" | "day"; label: string; count: number; isToday?: boolean; collapsed?: boolean; onToggleCollapse?: () => void; - onAddWo: () => void; + onAddWo?: () => void; }; export function WoTableSectionHeader({ @@ -20,72 +21,30 @@ export function WoTableSectionHeader({ onToggleCollapse, onAddWo, }: WoTableSectionHeaderProps) { - const isUnscheduled = variant === "unscheduled"; - - if (isUnscheduled) { - const toggleCollapse = () => { - onToggleCollapse?.(); - }; - + if (variant === "unassigned") { return ( - - - + onToggleCollapse?.()} + /> + ); + } + + if (variant === "unscheduled") { + return ( + onToggleCollapse?.()} + onAddWo={onAddWo} + /> ); } @@ -139,15 +98,17 @@ export function WoTableSectionHeader({ Today )} - + {Boolean(onAddWo) && ( + + )} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 09f819b6..c52a99bf 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -7,6 +7,7 @@ import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/t import { buildDayGroups, filterByBarAndSearch, + partitionBoardRows, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -38,10 +39,13 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { return displayRows; }, [displayRows, filters.barFilters, filters.search, advActive]); - const { scheduled, unscheduled } = useMemo( - () => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)), - [advActive, advResults, filtered], - ); + const { unassigned, scheduled, unscheduled } = useMemo(() => { + if (advActive) { + const split = splitScheduledUnscheduled(advResults); + return { unassigned: [], ...split }; + } + return partitionBoardRows(filtered); + }, [advActive, advResults, filtered]); const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); @@ -49,7 +53,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { const searchActive = filters.search.trim() !== ""; const visibleRows = advActive ? advResults : filtered; const noSearchResults = visibleRows.length === 0 && searchActive; - const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; + const isEmptyWeek = + !advActive && + scheduled.length === 0 && + unassigned.length === 0 && + unscheduled.length === 0 && + !searchActive; const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups; const todayKey = @@ -70,6 +79,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { refetch, advActive, advResults, + unassigned, unscheduled, displayGroups, searchActive, @@ -79,6 +89,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { draftDayKey: local.draftDayKey, draft: local.draft, setDraft: local.setDraft, + unassignedOpen: local.unassignedOpen, + setUnassignedOpen: local.setUnassignedOpen, unschedOpen: local.unschedOpen, setUnschedOpen: local.setUnschedOpen, selectedId: local.selectedId, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts index 633db819..9401ba1f 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-local-state.ts @@ -158,6 +158,8 @@ export function useWorkOrdersTableLocalState({ setDraft: ui.setDraft, startDayDraft: ui.startDayDraft, cancelDraft: ui.cancelDraft, + unassignedOpen: ui.unassignedOpen, + setUnassignedOpen: ui.setUnassignedOpen, unschedOpen: ui.unschedOpen, setUnschedOpen: ui.setUnschedOpen, selectedId: ui.selectedId, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts index ad0797e9..c1c8953f 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { EMPTY_TABLE_DRAFT, + UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, type WorkOrderTableDraft, } from "@/domain/work-orders/types/work-order-table-row"; @@ -11,7 +12,7 @@ export type DragVizState = { }; function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string { - if (key === UNSCHEDULED_DAY_KEY) { + if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) { return ""; } if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) { @@ -31,10 +32,11 @@ function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: return ""; } -/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */ +/** Draft, selection, pinned-section accordions, drag viz and flash scroll helpers. */ export function useWorkOrdersTableUiState(weekMonday: string) { const [draftDayKey, setDraftDayKey] = useState(null); const [draft, setDraft] = useState(EMPTY_TABLE_DRAFT); + const [unassignedOpen, setUnassignedOpen] = useState(true); const [unschedOpen, setUnschedOpen] = useState(true); const [selectedId, setSelectedId] = useState(null); const [flashId, setFlashId] = useState(null); @@ -81,6 +83,8 @@ export function useWorkOrdersTableUiState(weekMonday: string) { setDraft, startDayDraft, cancelDraft, + unassignedOpen, + setUnassignedOpen, unschedOpen, setUnschedOpen, selectedId, diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 4bcb4b60..ab7ea16d 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -115,3 +115,4 @@ export type WorkOrderDayGroup = { export type WorkOrderTablePatch = Partial; export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const; +export const UNASSIGNED_DAY_KEY = "__unassigned__" as const; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts index f266bc6a..265344da 100644 --- a/src/domain/work-orders/types/work-order.ts +++ b/src/domain/work-orders/types/work-order.ts @@ -57,6 +57,7 @@ export type { export { EMPTY_TABLE_DRAFT, + UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, WORK_ORDERS_TABLE_PAGE_SIZE, } from "./work-order-table-row"; diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index be0b6ded..b1245ee7 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -6,6 +6,7 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates"; +import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]); @@ -90,16 +91,47 @@ export function filterByBarAndSearch( } } - // Design weekScoped: undated (!scheduledOn) stay visible every week; targetWeek - // is a Schedule-cell marker only, never a week-inclusion predicate. - if (row.scheduledOn && (row.scheduledOn < weekFrom || row.scheduledOn > weekTo)) { - return false; + if (row.scheduledOn) { + if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) { + return false; + } + } else if (row.targetWeek) { + if (!isDateInWeek(row.targetWeek, bar.weekMonday)) { + return false; + } } return true; }); } +export function isUnassignedBoardRow(row: WorkOrderTableRow): boolean { + if (TERMINAL_STATUSES.has(row.status)) { + return false; + } + return !row.dispatcherId; +} + +export function partitionBoardRows(rows: WorkOrderTableRow[]): { + unassigned: WorkOrderTableRow[]; + unscheduled: WorkOrderTableRow[]; + scheduled: WorkOrderTableRow[]; +} { + const unassigned: WorkOrderTableRow[] = []; + const remaining: WorkOrderTableRow[] = []; + + for (const row of rows) { + if (isUnassignedBoardRow(row)) { + unassigned.push(row); + } else { + remaining.push(row); + } + } + + const { scheduled, unscheduled } = splitScheduledUnscheduled(remaining); + return { unassigned, unscheduled, scheduled }; +} + export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): { scheduled: WorkOrderTableRow[]; unscheduled: WorkOrderTableRow[]; diff --git a/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx similarity index 55% rename from src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx rename to src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index 82551058..d510af3f 100644 --- a/src/test/app/(protected)/workorders/wo-table-body-unscheduled.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body"; import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types"; @@ -62,6 +63,7 @@ function baseTableData( refetch: vi.fn(), advActive: false, advResults: [], + unassigned: [], unscheduled: [], displayGroups: [], searchActive: false, @@ -71,6 +73,8 @@ function baseTableData( draftDayKey: null, draft: null, setDraft: vi.fn(), + unassignedOpen: true, + setUnassignedOpen: vi.fn(), unschedOpen: true, setUnschedOpen: vi.fn(), selectedId: null, @@ -95,28 +99,43 @@ function baseTableData( } as ReturnTypeOfUseWorkOrdersTableData; } -describe("WoTableBody Unscheduled vs day groups", () => { - it("renders Unscheduled above day groups and keeps dated unassigned under the day header", () => { +describe("WoTableBody Unassigned queue (SH-165)", () => { + it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => { const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" }); - const undated = makeRow({ + const undatedUnassigned = makeRow({ id: "us1", woNumber: "25021", scheduledOn: "", dayGroup: "", dayLabel: "", }); + const undatedAssigned = makeRow({ + id: "us-assigned", + woNumber: "25022", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const datedAssigned = makeRow({ + id: "day-assigned", + woNumber: "25023", + dispatcherId: "sarah", + dispatcherName: "Sarah", + }); const tableData = baseTableData({ - unscheduled: [undated], + unassigned: [datedUnassigned, undatedUnassigned], + unscheduled: [undatedAssigned], displayGroups: [ { key: "06/02", label: "TUE · 06/02", date: "2026-06-02", - items: [datedUnassigned], + items: [datedAssigned], }, ], - unschedOpen: true, }); render( @@ -133,21 +152,54 @@ describe("WoTableBody Unscheduled vs day groups", () => {
    + — + - — - - — -
    +
    + + {Boolean(onAddWo) && ( + + )} +
    +
    -
    - - -
    -
    , ); - expect(screen.getByRole("button", { name: "Unscheduled" })).toBeInTheDocument(); - expect(screen.getByText("TUE · 06/02")).toBeInTheDocument(); - - const unscheduledHeader = screen.getByRole("button", { name: "Unscheduled" }); + const unassignedHeader = screen.getByRole("button", { + name: "Unassigned, 2 work orders, expanded", + }); + const unscheduledHeader = screen.getByRole("button", { + name: "Unscheduled, 1 work order, expanded", + }); const dayLabel = screen.getByText("TUE · 06/02"); - const undatedRow = screen.getByTestId("row-us1"); - const datedRow = screen.getByTestId("row-u-dated"); - expect( - unscheduledHeader.compareDocumentPosition(undatedRow) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect( - dayLabel.compareDocumentPosition(datedRow) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect(screen.queryByTestId("row-u-dated")).toBeInTheDocument(); - expect(screen.getAllByTestId(/row-/)).toHaveLength(2); + expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + expect(screen.getByTestId("row-u-dated")).toBeInTheDocument(); + expect(screen.getByTestId("row-us1")).toBeInTheDocument(); + expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument(); + expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument(); + expect(screen.getAllByTestId(/row-/)).toHaveLength(4); + }); + + it("collapses and expands the Unassigned section via the disclosure control", async () => { + const user = userEvent.setup(); + const setUnassignedOpen = vi.fn(); + const tableData = baseTableData({ + unassigned: [makeRow({ id: "u1", woNumber: "24991" })], + setUnassignedOpen, + }); + + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber}
    , + ); + + await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" })); + expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function)); }); }); diff --git a/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts b/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts deleted file mode 100644 index 4c919a28..00000000 --- a/src/test/domain/work-orders/utils/board-unscheduled-design-parity.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; -import { - filterByBarAndSearch, - splitScheduledUnscheduled, -} from "@/domain/work-orders/utils/table-filter-utils"; -import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; - -/** - * Design oracle — mirrors shoc-desing/src/pages/WorkOrders.tsx: - * - weekScoped (~L9734): !scheduledOn || (scheduledOn >= wStart && scheduledOn <= wEnd) - * - split (~L9762): unscheduled = !scheduledOn; dated-but-unassigned stay in day groups - */ - -type DesignRow = Pick; - -function designWeekScoped(rows: DesignRow[], weekStart: string, weekEnd: string): DesignRow[] { - return rows.filter( - (row) => !row.scheduledOn || (row.scheduledOn >= weekStart && row.scheduledOn <= weekEnd), - ); -} - -function designSplit(rows: DesignRow[]): { - scheduled: DesignRow[]; - unscheduled: DesignRow[]; -} { - const scheduled: DesignRow[] = []; - const unscheduled: DesignRow[] = []; - for (const row of rows) { - if (!row.scheduledOn) { - unscheduled.push(row); - } else { - scheduled.push(row); - } - } - return { scheduled, unscheduled }; -} - -function makeRow(overrides: Partial = {}): WorkOrderTableRow { - return { - id: "1", - woNumber: "24991", - site: "CLV2", - type: "PM", - dispatcherId: "u1", - dispatcherName: "Alice", - scheduledOn: "2026-06-02", - targetWeek: "", - dayGroup: "06/02", - dayLabel: "TUE · 06/02", - location: "Cleveland, OH", - locationId: "", - pm: "Backflow", - serviceNotes: "", - extraServices: [], - company: "", - tech: "", - techPhone: "", - apptStart: "", - apptEnd: "", - apptTime: "—", - status: "Incomplete", - docStatus: "No", - pocName: "", - pocPhone: "", - pocNotes: "", - dueDate: "", - rescheduleCount: 0, - carriedOver: 0, - originalWeek: "", - originalDate: "", - isPastDue: false, - scheduleWeekOnly: false, - mediaCount: 0, - title: "", - priority: "", - vendorId: "", - primaryDispatchId: null, - rowVersion: "", - dispatchRowVersion: "", - flagColor: null, - ...overrides, - }; -} - -function idsOf(rows: { id: string | number }[]): string[] { - return rows.map((row) => String(row.id)).sort(); -} - -describe("board Unscheduled design parity", () => { - const weekMonday = "2026-06-01"; - const [weekStart, weekEnd] = weekRange(weekMonday); - - const fixtures: WorkOrderTableRow[] = [ - makeRow({ - id: "u-dated", - dispatcherId: "", - dispatcherName: "", - scheduledOn: "2026-06-02", - ...deriveDayFields("2026-06-02"), - }), - makeRow({ - id: "us-undated", - dispatcherId: "", - dispatcherName: "", - scheduledOn: "", - dayGroup: "", - dayLabel: "", - }), - makeRow({ - id: "ua-assigned-undated", - dispatcherId: "sarah", - dispatcherName: "Sarah", - scheduledOn: "", - dayGroup: "", - dayLabel: "", - }), - makeRow({ - id: "tw-other-week", - dispatcherId: "sarah", - dispatcherName: "Sarah", - scheduledOn: "", - targetWeek: "2026-07-06", - dayGroup: "", - dayLabel: "", - }), - makeRow({ - id: "dated-other-week", - scheduledOn: "2026-06-15", - ...deriveDayFields("2026-06-15"), - }), - ]; - - const bar: WorkOrderBarFilters = { - weekMonday, - dispatcherIds: new Set(), - type: "All", - }; - - it("matches design weekScoped ID set via filterByBarAndSearch", () => { - const designIds = idsOf(designWeekScoped(fixtures, weekStart, weekEnd)); - const feIds = idsOf(filterByBarAndSearch(fixtures, bar, "")); - expect(feIds).toEqual(designIds); - expect(feIds).toContain("tw-other-week"); - expect(feIds).not.toContain("dated-other-week"); - }); - - it("matches design split: dated unassigned in scheduled, undated in Unscheduled", () => { - const scoped = filterByBarAndSearch(fixtures, bar, ""); - const feSplit = splitScheduledUnscheduled(scoped); - const designSplitResult = designSplit(designWeekScoped(fixtures, weekStart, weekEnd)); - - expect(idsOf(feSplit.unscheduled)).toEqual(idsOf(designSplitResult.unscheduled)); - expect(idsOf(feSplit.scheduled)).toEqual(idsOf(designSplitResult.scheduled)); - - expect(idsOf(feSplit.scheduled)).toContain("u-dated"); - expect(idsOf(feSplit.unscheduled)).not.toContain("u-dated"); - expect(idsOf(feSplit.unscheduled)).toContain("tw-other-week"); - expect(idsOf(feSplit.unscheduled)).toEqual( - expect.arrayContaining(["us-undated", "ua-assigned-undated", "tw-other-week"]), - ); - }); -}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index b059bbd3..7316b094 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -5,7 +5,9 @@ import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order- import { buildDayGroups, filterByBarAndSearch, + isUnassignedBoardRow, matchesAdvancedFilters, + partitionBoardRows, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; @@ -83,18 +85,24 @@ describe("deriveDayFields", () => { describe("filterByBarAndSearch", () => { const weekMonday = "2026-06-01"; - it("keeps undated rows for any week, including targetWeek outside the active week", () => { + it("keeps undated rows without targetWeek for any active week", () => { const undated = makeRow({ id: "us1", scheduledOn: "", targetWeek: "" }); + + const ids = filterByBarAndSearch([undated], bar(weekMonday), "").map((row) => row.id); + expect(ids).toEqual(["us1"]); + }); + + it("excludes undated rows whose targetWeek is outside the active week", () => { const undatedOtherTarget = makeRow({ id: "tw1", scheduledOn: "", targetWeek: "2026-07-06", }); - const ids = filterByBarAndSearch([undated, undatedOtherTarget], bar(weekMonday), "").map( + const ids = filterByBarAndSearch([undatedOtherTarget], bar(weekMonday), "").map( (row) => row.id, ); - expect(ids).toEqual(["us1", "tw1"]); + expect(ids).toEqual([]); }); it("excludes dated rows outside the active week", () => { @@ -114,6 +122,59 @@ describe("filterByBarAndSearch", () => { }); }); +describe("isUnassignedBoardRow", () => { + it("treats empty dispatcher as unassigned unless terminal", () => { + expect(isUnassignedBoardRow(makeRow({ dispatcherId: "" }))).toBe(true); + expect(isUnassignedBoardRow(makeRow({ dispatcherId: "u1" }))).toBe(false); + expect(isUnassignedBoardRow(makeRow({ dispatcherId: "", status: "Completed" }))).toBe(false); + }); +}); + +describe("partitionBoardRows", () => { + it("pins unassigned rows first and keeps assigned undated rows in Unscheduled", () => { + const datedUnassigned = makeRow({ + id: "u-dated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-06-02", + ...deriveDayFields("2026-06-02"), + }); + const undatedUnassigned = makeRow({ + id: "us-undated", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const undatedAssigned = makeRow({ + id: "us-assigned", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "", + dayGroup: "", + dayLabel: "", + }); + const datedAssigned = makeRow({ id: "day-assigned", dispatcherId: "sarah" }); + + const result = partitionBoardRows([ + datedUnassigned, + undatedUnassigned, + undatedAssigned, + datedAssigned, + ]); + + expect(result.unassigned.map((row) => row.id)).toEqual(["u-dated", "us-undated"]); + expect(result.unscheduled.map((row) => row.id)).toEqual(["us-assigned"]); + expect(result.scheduled.map((row) => row.id)).toEqual(["day-assigned"]); + + const allIds = [...result.unassigned, ...result.unscheduled, ...result.scheduled].map( + (row) => row.id, + ); + expect(new Set(allIds).size).toBe(allIds.length); + }); +}); + describe("splitScheduledUnscheduled", () => { it("splits rows without scheduledOn into unscheduled", () => { const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" }); @@ -123,23 +184,6 @@ describe("splitScheduledUnscheduled", () => { expect(result.unscheduled).toHaveLength(1); expect(result.unscheduled[0].id).toBe("b"); }); - - it("keeps dated unassigned in scheduled and undated in unscheduled without duplicate ids", () => { - const datedUnassigned = makeRow({ - id: "u-dated", - dispatcherId: "", - dispatcherName: "", - scheduledOn: "2026-06-02", - ...deriveDayFields("2026-06-02"), - }); - const undated = makeRow({ id: "us-undated", scheduledOn: "", dispatcherId: "" }); - const result = splitScheduledUnscheduled([datedUnassigned, undated]); - - expect(result.scheduled.map((row) => row.id)).toEqual(["u-dated"]); - expect(result.unscheduled.map((row) => row.id)).toEqual(["us-undated"]); - const allIds = [...result.scheduled, ...result.unscheduled].map((row) => row.id); - expect(new Set(allIds).size).toBe(allIds.length); - }); }); describe("buildDayGroups", () => { From c721b407b6dabf3c6084d444da3c1a2b6c65e625 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Fri, 14 Aug 2026 10:36:45 -0300 Subject: [PATCH 18/31] fix(work-orders): merge dev and gate uplift create on pending and allowance Hide a second create while a pending request exists, surface remaining auto-approval in the dialog, and refresh the branch onto current origin/dev. --- docs/work-orders/pending-be-contract.md | 7 +++ .../uplifts/work-order-uplift-create-form.tsx | 15 ++++++ .../work-order-uplifts-dialog-content.tsx | 19 ++++++- .../uplifts/work-order-uplifts-dialog.tsx | 4 ++ .../work-orders/utils/uplift-display-utils.ts | 25 +++++++++ .../work-order-uplifts-affordances.test.tsx | 53 +++++++++++++------ .../utils/uplift-display-utils.test.ts | 28 ++++++++++ 7 files changed, 133 insertions(+), 18 deletions(-) diff --git a/docs/work-orders/pending-be-contract.md b/docs/work-orders/pending-be-contract.md index c97256aa..0e5af48d 100644 --- a/docs/work-orders/pending-be-contract.md +++ b/docs/work-orders/pending-be-contract.md @@ -26,6 +26,13 @@ Board row `upliftSummary` (`{ hasUplift, pendingCount, primaryStatus?, amount? } Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`. +SH-196 producer invariants (backend #64): + +- One open (`pending`) request per work order at a time — cancel-and-refile to submit another. +- Auto-approval cap is cumulative per WO: `$500`, or `$5,000` for Emergency. Consumed = sum of current `auto_approved` amounts (revoked amounts are freed). +- Amount ≤ remaining allowance → `auto_approved`; otherwise `pending` (admin queue). +- Cancelling a WO withdraws any pending uplift in the same action. + ## Search query params (`GET workorders/board/search`) | Param | Meaning | diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx index f9de4392..8e0902e6 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx @@ -1,14 +1,28 @@ import { useState } from "react"; import { Button, TextField } from "@mui/material"; import { Text } from "@/components/ui/text"; +import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils"; type WorkOrderUpliftCreateFormProps = { pending?: boolean; + remainingAllowance: number; onSubmit: (input: { amount: number; notes: string }) => void | Promise; }; +function amountHelperText(parsedAmount: number, remainingAllowance: number): string { + const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`; + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + return remaining; + } + if (parsedAmount <= remainingAllowance) { + return `${remaining} This request will auto-approve.`; + } + return `${remaining} This request will go to the admin approval queue.`; +} + export function WorkOrderUpliftCreateForm({ pending = false, + remainingAllowance, onSubmit, }: WorkOrderUpliftCreateFormProps) { const [amount, setAmount] = useState(""); @@ -39,6 +53,7 @@ export function WorkOrderUpliftCreateForm({ size="small" value={amount} onChange={(event) => setAmount(event.target.value)} + helperText={amountHelperText(parsedAmount, remainingAllowance)} slotProps={{ htmlInput: { min: 0, step: "0.01" } }} /> void; @@ -32,6 +34,7 @@ export function WorkOrderUpliftsDialogContent({ isLoading, error, uplifts, + remainingAllowance, actionPending, createPending, onRetry, @@ -39,6 +42,8 @@ export function WorkOrderUpliftsDialogContent({ onCancelUplift, onRevokeUplift, }: WorkOrderUpliftsDialogContentProps) { + const hasOpenRequest = hasOpenWorkOrderUplift(uplifts); + return ( <> {readOnly && ( @@ -81,8 +86,18 @@ export function WorkOrderUpliftsDialogContent({ /> ))} - {!readOnly && !isLoading && ( - + {!readOnly && !isLoading && hasOpenRequest && ( + + Cancel the pending request to file a new one. + + )} + + {!readOnly && !isLoading && !hasOpenRequest && ( + )} ); diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx index 488df1f7..57de111a 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx @@ -11,6 +11,7 @@ import { useWorkOrderUplifts, } from "@/domain/work-orders/use-cases/use-work-order-uplifts"; import { + getRemainingUpliftAllowance, isWorkOrderUpliftsReadOnly, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; @@ -69,6 +70,8 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD setRevokeTarget({ id: upliftId, status: uplift.status }); }; + const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts); + return ( <> @@ -82,6 +85,7 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD isLoading={isLoading} error={error} uplifts={uplifts} + remainingAllowance={remainingAllowance} actionPending={actionPending} createPending={createUplift.isPending} onRetry={() => void refetch()} diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts index 49c9da15..09227f4f 100644 --- a/src/domain/work-orders/utils/uplift-display-utils.ts +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -104,3 +104,28 @@ export function canRevokeWorkOrderUplift( export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean { return status === "approved"; } + +export const UPLIFT_STANDARD_ALLOWANCE_USD = 500; +export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000; + +export function getUpliftAllowanceCap(workOrderType: string): number { + return workOrderType === "Emergency" + ? UPLIFT_EMERGENCY_ALLOWANCE_USD + : UPLIFT_STANDARD_ALLOWANCE_USD; +} + +export function hasOpenWorkOrderUplift( + uplifts: ReadonlyArray>, +): boolean { + return uplifts.some((uplift) => uplift.status === "pending"); +} + +export function getRemainingUpliftAllowance( + workOrderType: string, + uplifts: ReadonlyArray>, +): number { + const consumed = uplifts + .filter((uplift) => uplift.status === "auto_approved") + .reduce((sum, uplift) => sum + uplift.amount, 0); + return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed); +} diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx index 79e70ad7..9f7f67a8 100644 --- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -3,8 +3,26 @@ import { describe, expect, it, vi } from "vitest"; import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; import { renderWithProviders } from "@/test/test-utils"; +const pendingUplift: WorkOrderUplift = { + id: 1, + status: "pending", + amount: 450, + notes: "Extra work", + requestedAt: "2026-07-20T10:00:00Z", + requestedById: "vendor-1", + requestedByName: "Vendor", + decidedAt: "", + decidedByName: "", + decisionNote: "", +}; + +const mockState = vi.hoisted(() => ({ + uplifts: [] as WorkOrderUplift[], +})); + const baseRow: WorkOrderTableRow = { id: 42, woNumber: "WO-42", @@ -53,20 +71,7 @@ const baseRow: WorkOrderTableRow = { vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ useWorkOrderUplifts: () => ({ - data: [ - { - id: 1, - status: "pending", - amount: 450, - notes: "Extra work", - requestedAt: "2026-07-20T10:00:00Z", - requestedById: "vendor-1", - requestedByName: "Vendor", - decidedAt: "", - decidedByName: "", - decisionNote: "", - }, - ], + data: mockState.uplifts, isLoading: false, error: null, refetch: vi.fn(), @@ -100,17 +105,33 @@ describe("UpliftCell affordances", () => { }); describe("WorkOrderUpliftsDialog affordances", () => { - it("shows create form for editable work orders", () => { + it("hides create form when a pending request already exists", () => { + mockState.uplifts = [pendingUplift]; + renderWithProviders(, { withAuth: true, }); expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument(); + expect(screen.getByText(/cancel the pending request to file a new one/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument(); + }); + + it("shows create form with remaining allowance when no pending request exists", () => { + mockState.uplifts = []; + + renderWithProviders(, { + withAuth: true, + }); + + expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument(); + expect(screen.getByText(/remaining auto-approval: \$500/i)).toBeInTheDocument(); }); it("hides create and cancel actions when work order is completed", () => { + mockState.uplifts = [pendingUplift]; + renderWithProviders( , { withAuth: true }, diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts index c441ec4f..a05580f6 100644 --- a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { canRevokeWorkOrderUplift, + getRemainingUpliftAllowance, + hasOpenWorkOrderUplift, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; @@ -67,3 +69,29 @@ describe("upliftRevokeRequiresReason", () => { expect(upliftRevokeRequiresReason("auto_approved")).toBe(false); }); }); + +describe("SH-196 allowance helpers", () => { + it("uses a $500 cap for non-emergency work orders", () => { + expect(getRemainingUpliftAllowance("PM", [])).toBe(500); + expect(getRemainingUpliftAllowance("PM", [{ status: "auto_approved", amount: 400 }])).toBe(100); + }); + + it("uses a $5,000 cap for emergency work orders", () => { + expect(getRemainingUpliftAllowance("Emergency", [])).toBe(5000); + }); + + it("ignores revoked and pending amounts when computing remaining allowance", () => { + expect( + getRemainingUpliftAllowance("PM", [ + { status: "auto_approved", amount: 200 }, + { status: "revoked", amount: 300 }, + { status: "pending", amount: 400 }, + ]), + ).toBe(300); + }); + + it("detects an open pending request", () => { + expect(hasOpenWorkOrderUplift([{ status: "pending" }])).toBe(true); + expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false); + }); +}); From 5d7ac297e452a0060557fa5f98bef80686367683 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 14 Aug 2026 10:55:06 -0300 Subject: [PATCH 19/31] fix(work-orders): drop vendor string from Completed closability (SH-114) --- src/domain/work-orders/utils/wo-closability.ts | 3 --- src/test/domain/work-orders/utils/wo-closability.test.ts | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index f8ed2972..3117f600 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -73,9 +73,6 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { if (!row.tech.trim()) { gaps.push("Technician"); } - if (!row.company.trim()) { - gaps.push("Company / vendor"); - } if (!row.completedDate.trim()) { gaps.push("Completed Date"); } diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 228f3dbe..29f82f24 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -64,8 +64,8 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual(["Technician"]); }); - it("does not let a technician satisfy a missing company requirement", () => { - expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual(["Company / vendor"]); + it("does not block Completed when vendor/company is empty if technician is assigned", () => { + expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual([]); }); it("lists field gaps before uplift and document gaps", () => { @@ -87,7 +87,6 @@ describe("getClosabilityGaps", () => { ).toEqual([ "Dispatcher", "Technician", - "Company / vendor", "Completed Date", "WO number", "Service", From 37a6098ecbf31f5227514107addc3d9dce9988e7 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 17 Aug 2026 13:54:05 -0300 Subject: [PATCH 20/31] fix(work-orders): require company not technician for Completed (SH-183) --- .../work-orders/utils/wo-closability.ts | 16 +++++--- .../status-cell-pending-uplift.test.tsx | 40 +++++++++++++++++++ .../work-orders/utils/wo-closability.test.ts | 13 +++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 3117f600..024a1ca5 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -4,13 +4,13 @@ import type { } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; -/** Fields required to evaluate whether a WO may be marked Completed (SH-114 ∪ pending uplift). */ +/** Fields required to evaluate whether a WO may be marked Completed (SH-183 ∪ pending uplift). */ export type WoClosabilityInput = { dispatcherId: string; dispatcherName: string; - /** Vendor/company assignment on the board row. */ + /** Company / vendor assignment — required for Completed (SH-183). */ company: string; - /** Technician assigned to the work order. */ + /** Technician is optional for closability; modeled separately from company. */ tech: string; /** Work completed date (API CompletedDate). Empty until set. */ completedDate: string; @@ -51,6 +51,10 @@ function hasDispatcher(row: Pick = {}): WorkOrderTableRow woNumber: "1001", site: "BK5", type: "PM", + severity: null, dispatcherId: "u1", dispatcherName: "Alex", scheduledOn: "2026-07-15", @@ -116,4 +117,43 @@ describe("StatusCell pending uplift closability", () => { expect(onChangeStatus).toHaveBeenCalledWith("Completed"); }); + + it("disables Completed when company is missing (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + + const completed = screen.getByRole("button", { name: /completed/i }); + expect(completed).toBeDisabled(); + expect(completed).toHaveAttribute("title", "Missing: Company"); + fireEvent.click(completed); + expect(onChangeStatus).not.toHaveBeenCalled(); + }); + + it("allows Completed when technician is empty if company is set (SH-183)", () => { + const onChangeStatus = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /in progress/i })); + fireEvent.click(screen.getByRole("button", { name: /completed/i })); + + expect(onChangeStatus).toHaveBeenCalledWith("Completed"); + }); }); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 29f82f24..7c761560 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -60,12 +60,15 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); - it("blocks Completed when technician is missing", () => { - expect(getClosabilityGaps(makeInput({ tech: "" }))).toEqual(["Technician"]); + it("allows company without technician (SH-183)", () => { + const gaps = getClosabilityGaps(makeInput({ tech: "" })); + expect(gaps).toEqual([]); + expect(gaps).not.toContain("Technician"); }); - it("does not block Completed when vendor/company is empty if technician is assigned", () => { - expect(getClosabilityGaps(makeInput({ company: "" }))).toEqual([]); + it("blocks Completed when company is missing even if technician is assigned", () => { + expect(getClosabilityGaps(makeInput({ company: "", tech: "Sam" }))).toEqual(["Company"]); + expect(getClosabilityGaps(makeInput({ company: " ", tech: "Sam" }))).toEqual(["Company"]); }); it("lists field gaps before uplift and document gaps", () => { @@ -86,7 +89,7 @@ describe("getClosabilityGaps", () => { ), ).toEqual([ "Dispatcher", - "Technician", + "Company", "Completed Date", "WO number", "Service", From 958467fe400442bc13f98ea30fbd8feefef60259 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Mon, 17 Aug 2026 14:57:39 -0300 Subject: [PATCH 21/31] fix(work-orders): load SH-165 Unassigned queue from global board search --- .../_components/list/table/wo-table-body.tsx | 22 +++++-- .../_hooks/use-work-orders-board-source.ts | 29 ++++++++- .../_hooks/use-work-orders-table-data.ts | 41 ++++++++++-- .../_hooks/use-work-orders-table-ui-state.ts | 2 +- .../api/fetch-unassigned-board-rows.ts | 29 +++++++++ .../work-orders/types/work-order-board.ts | 12 ++++ .../use-cases/use-work-orders-list.ts | 13 ++++ .../work-orders/utils/board-query-params.ts | 22 +++++++ .../work-orders/utils/table-filter-utils.ts | 45 +++++++++++++ src/infra/query-key/query-key.ts | 1 + .../wo-table-unassigned-queue.test.tsx | 50 ++++++++++++++ .../wo-table-unscheduled-highlight.test.tsx | 4 ++ .../api/fetch-unassigned-board-rows.test.ts | 52 +++++++++++++++ .../utils/board-query-params.test.ts | 21 ++++++ .../utils/table-filter-utils.test.ts | 65 +++++++++++++++++++ 15 files changed, 393 insertions(+), 15 deletions(-) create mode 100644 src/domain/work-orders/api/fetch-unassigned-board-rows.ts create mode 100644 src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index 3ffd7172..5b2d5b38 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -1,10 +1,12 @@ import { Fragment, type ReactNode } from "react"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { Text } from "@/components/ui/text"; import { UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, + type WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; +import { WO_TABLE_COLS } from "./wo-table-cols"; import { WoTableSectionHeader } from "./wo-table-section-header"; type WoTableBodyProps = { @@ -19,9 +21,11 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo } const showUnassigned = - tableData.unassigned.length > 0 || tableData.draftDayKey === UNASSIGNED_DAY_KEY; + (tableData.unassigned?.length ?? 0) > 0 || + tableData.draftDayKey === UNASSIGNED_DAY_KEY || + Boolean(tableData.unassignedError); const showUnscheduled = - tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; return ( @@ -37,7 +41,17 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo {tableData.unassignedOpen && ( <> {renderInlineRow(UNASSIGNED_DAY_KEY)} - {tableData.unassigned.map((row) => renderRow(row))} + {Boolean(tableData.unassignedError) && ( + + + + {tableData.unassignedError?.message ?? + "Unable to load unassigned work orders."} + + + + )} + {(tableData.unassigned ?? []).map((row) => renderRow(row))} )} 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..11771ca7 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 @@ -1,22 +1,28 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; import { useWorkOrdersBoard, useWorkOrdersBoardSearch, + useWorkOrdersUnassignedQueue, } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderLocations, useWorkOrderVendors, } from "@/domain/work-orders/use-cases/use-work-order-detail"; import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { UseQueryResult } from "@tanstack/react-query"; type FiltersReturn = ReturnType; +const EMPTY_UNASSIGNED_QUEUE: WorkOrderTableRow[] = []; + export type WorkOrdersBoardSource = { advActive: boolean; data: WorkOrderBoardListResult | undefined; + unassignedQueue: WorkOrderTableRow[]; + unassignedError: Error | null; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -67,13 +73,30 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar error: searchError, refetch: refetchSearch, } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); + const { + data: queueData, + isFetching: queueFetching, + error: queueError, + refetch: refetchQueue, + } = useWorkOrdersUnassignedQueue(!advActive); + + const refetchBoardWithQueue: UseQueryResult["refetch"] = + useCallback( + async (options) => { + const [boardResult] = await Promise.all([refetchBoard(options), refetchQueue()]); + return boardResult; + }, + [refetchBoard, refetchQueue], + ); return { advActive, data: advActive ? searchData : boardData, + unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE), + unassignedError: advActive ? null : (queueError ?? null), isLoading: advActive ? searchLoading : boardLoading, - isFetching: advActive ? searchFetching : boardFetching, + isFetching: advActive ? searchFetching : boardFetching || queueFetching, error: advActive ? searchError : boardError, - refetch: advActive ? refetchSearch : refetchBoard, + refetch: advActive ? refetchSearch : refetchBoardWithQueue, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index c52a99bf..469fd465 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -3,11 +3,14 @@ import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hoo import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source"; import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { buildDayGroups, + composeBoardSections, filterByBarAndSearch, - partitionBoardRows, + mergeWeeklyAndUnassignedQueue, + rowIdSet, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -15,12 +18,30 @@ export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work- type FiltersReturn = ReturnType; +const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = []; + export function useWorkOrdersTableData(filters: FiltersReturn) { - const { advActive, data, isLoading, isFetching, error, refetch } = - useWorkOrdersBoardSource(filters); + const { + advActive, + data, + unassignedQueue, + unassignedError, + isLoading, + isFetching, + error, + refetch, + } = useWorkOrdersBoardSource(filters); + + const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS; + const items = useMemo( + () => mergeWeeklyAndUnassignedQueue(weeklyItems, unassignedQueue), + [weeklyItems, unassignedQueue], + ); + const weeklyIds = useMemo(() => rowIdSet(weeklyItems), [weeklyItems]); + const queueIds = useMemo(() => rowIdSet(unassignedQueue), [unassignedQueue]); const local = useWorkOrdersTableLocalState({ - items: data?.items ?? [], + items, weekMonday: filters.weekMonday, advActive, isFetching, @@ -44,8 +65,13 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { const split = splitScheduledUnscheduled(advResults); return { unassigned: [], ...split }; } - return partitionBoardRows(filtered); - }, [advActive, advResults, filtered]); + return composeBoardSections({ + displayRows, + weekRows: filtered, + weeklyIds, + queueIds, + }); + }, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]); const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]); const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]); @@ -80,6 +106,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advActive, advResults, unassigned, + unassignedError, unscheduled, displayGroups, searchActive, @@ -111,7 +138,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { scopeTotalCount: data?.totalCount ?? 0, totalCount: advActive ? (data?.totalCount ?? advResults.length) - : (data?.returnedCount ?? visibleRows.length), + : (data?.returnedCount ?? weeklyItems.length), search: filters.search, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts index 981e0f0f..196bfe69 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state.ts @@ -52,7 +52,7 @@ export function useWorkOrdersTableUiState(weekMonday: string) { (key: string, scheduledIso?: string) => { setDraftDayKey(key); // Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day. - if (key === UNSCHEDULED_DAY_KEY) { + if (key === UNSCHEDULED_DAY_KEY || key === UNASSIGNED_DAY_KEY) { setDraft({ ...EMPTY_TABLE_DRAFT }); return; } diff --git a/src/domain/work-orders/api/fetch-unassigned-board-rows.ts b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts new file mode 100644 index 00000000..4887496a --- /dev/null +++ b/src/domain/work-orders/api/fetch-unassigned-board-rows.ts @@ -0,0 +1,29 @@ +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { toUnassignedQueueQueryParams } from "@/domain/work-orders/utils/board-query-params"; + +const UNASSIGNED_QUEUE_MAX_PAGES = 50; + +/** Exhausts GET /board/search pages for the global Unassigned pin (SH-165). */ +export async function fetchUnassignedBoardRows(): Promise { + const rows: WorkOrderTableRow[] = []; + let page = 0; + let totalCount = Number.POSITIVE_INFINITY; + + while (rows.length < totalCount) { + if (page >= UNASSIGNED_QUEUE_MAX_PAGES) { + throw new Error( + "Unable to load the unassigned queue: search result set exceeded the safety page limit.", + ); + } + const result = await workOrdersApi.searchBoard(toUnassignedQueueQueryParams(page)); + rows.push(...result.items); + totalCount = result.totalCount; + if (result.items.length === 0) { + break; + } + page += 1; + } + + return rows; +} diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 51bbdc0b..04c885f9 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -41,6 +41,18 @@ export const LIFECYCLE_STATUS_API = { export type LifecycleStatusApi = (typeof LIFECYCLE_STATUS_API)[keyof typeof LIFECYCLE_STATUS_API]; +/** Non-terminal lifecycle statuses for the global Unassigned queue (SH-165). */ +export const NON_TERMINAL_LIFECYCLE_STATUSES: LifecycleStatusApi[] = [ + LIFECYCLE_STATUS_API.Incomplete, + LIFECYCLE_STATUS_API.Pending, + LIFECYCLE_STATUS_API.Scheduled, + LIFECYCLE_STATUS_API["En Route"], + LIFECYCLE_STATUS_API["On Site"], + LIFECYCLE_STATUS_API["In Progress"], + LIFECYCLE_STATUS_API.Rescheduled, + LIFECYCLE_STATUS_API["Pending Quote"], +]; + export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts index 46fd94ef..59998f5a 100644 --- a/src/domain/work-orders/use-cases/use-work-orders-list.ts +++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts @@ -1,4 +1,5 @@ import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrdersListParams, @@ -10,6 +11,7 @@ import type { WorkOrderBoardQueryParams, WorkOrderBoardSearchQueryParams, } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { queryKeys } from "@/infra/query-key/query-key"; export function useWorkOrdersList( @@ -46,6 +48,17 @@ export function useWorkOrdersBoardSearch( }); } +export function useWorkOrdersUnassignedQueue( + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.unassignedQueue(), + queryFn: fetchUnassignedBoardRows, + enabled, + placeholderData: keepPreviousData, + }); +} + export function useWorkOrderUsers(): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.dispatchers(), diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 6f4824fe..293aa597 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -6,6 +6,7 @@ import { DATE_RANGE_TO_PRESET, DOC_STATUS_API, LIFECYCLE_STATUS_API, + NON_TERMINAL_LIFECYCLE_STATUSES, type WorkOrderBoardQueryParams, type WorkOrderBoardSearchQueryParams, } from "@/domain/work-orders/types/work-order-board"; @@ -16,6 +17,14 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord export const BOARD_SEARCH_MAX_PAGE_SIZE = 100; +/** + * Wide Custom window for SH-165. GET /board/search defaults omitted + * `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would + * hide scheduled-unassigned WOs outside the current week. + */ +export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01"; +export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31"; + function toBoardDispatcherId(id: string): string { if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) { return BOARD_DISPATCHER_UNASSIGNED; @@ -175,3 +184,16 @@ export function buildBoardSearchParamsRecord( export function woTypeFilterIsOverdue(type: string): boolean { return type === "Overdue"; } + +/** Week-independent GET /board/search params for the pinned Unassigned queue. */ +export function toUnassignedQueueQueryParams(page = 0): WorkOrderBoardSearchQueryParams { + return { + page, + pageSize: BOARD_SEARCH_MAX_PAGE_SIZE, + dispatchers: [BOARD_DISPATCHER_UNASSIGNED], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + dateFrom: UNASSIGNED_QUEUE_DATE_FROM, + dateTo: UNASSIGNED_QUEUE_DATE_TO, + }; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 6c9df9ec..9c13d5bf 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -129,6 +129,51 @@ export function partitionBoardRows(rows: WorkOrderTableRow[]): { return { unassigned, unscheduled, scheduled }; } +export function rowIdSet(rows: WorkOrderTableRow[]): Set { + return new Set(rows.map((row) => String(row.id))); +} + +/** Weekly board rows win on id overlap so PATCH state stays authoritative. */ +export function mergeWeeklyAndUnassignedQueue( + weekly: WorkOrderTableRow[], + queue: WorkOrderTableRow[], +): WorkOrderTableRow[] { + if (queue.length === 0) { + return weekly; + } + const seen = rowIdSet(weekly); + const extras = queue.filter((row) => !seen.has(String(row.id))); + return extras.length === 0 ? weekly : [...weekly, ...extras]; +} + +type ComposeBoardSectionsInput = { + displayRows: WorkOrderTableRow[]; + weekRows: WorkOrderTableRow[]; + weeklyIds: ReadonlySet; + queueIds: ReadonlySet; +}; + +/** + * Pin is every non-terminal unassigned row (including out-of-week). + * Day/Unscheduled groups stay week-scoped and never repeat pin ids. + */ +export function composeBoardSections(input: ComposeBoardSectionsInput): { + unassigned: WorkOrderTableRow[]; + unscheduled: WorkOrderTableRow[]; + scheduled: WorkOrderTableRow[]; +} { + const unassigned = input.displayRows.filter(isUnassignedBoardRow); + const pinIds = rowIdSet(unassigned); + const weekAssigned = input.weekRows.filter((row) => { + const id = String(row.id); + if (pinIds.has(id) || isUnassignedBoardRow(row)) { + return false; + } + return input.weeklyIds.has(id) || !input.queueIds.has(id); + }); + return { unassigned, ...splitScheduledUnscheduled(weekAssigned) }; +} + export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): { scheduled: WorkOrderTableRow[]; unscheduled: WorkOrderTableRow[]; diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c775e00e..87eeda4e 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -17,6 +17,7 @@ export const queryKeys = { [...queryKeys.workOrders.all, "board", params ?? {}] as const, boardSearch: (params?: WorkOrderBoardSearchQueryParams) => [...queryKeys.workOrders.all, "boardSearch", params ?? {}] as const, + unassignedQueue: () => [...queryKeys.workOrders.all, "unassignedQueue"] as const, byLocation: (locationId: string | number, search = "") => [...queryKeys.workOrders.all, "byLocation", locationId, search] as const, detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const, diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index d510af3f..a5a371b0 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -11,9 +11,11 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow woNumber: "24991", site: "CLV2", type: "PM", + severity: null, dispatcherId: "", dispatcherName: "", scheduledOn: "2026-06-02", + completedDate: "", targetWeek: "", dayGroup: "06/02", dayLabel: "TUE · 06/02", @@ -64,6 +66,7 @@ function baseTableData( advActive: false, advResults: [], unassigned: [], + unassignedError: null, unscheduled: [], displayGroups: [], searchActive: false, @@ -202,4 +205,51 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { await user.click(screen.getByRole("button", { name: "Unassigned, 1 work order, expanded" })); expect(setUnassignedOpen).toHaveBeenCalledWith(expect.any(Function)); }); + + it("renders a scheduled unassigned WO from outside the selected week in the pin", () => { + const outOfWeek = makeRow({ + id: "out-week", + woNumber: "26001", + scheduledOn: "2026-07-15", + dayGroup: "07/15", + dayLabel: "WED · 07/15", + }); + + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber}
    , + ); + + expect(screen.getByTestId("row-out-week")).toBeInTheDocument(); + expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument(); + }); + + it("keeps the Unassigned pin visible when the queue query fails", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber}
    , + ); + + expect(screen.getByText("queue unavailable")).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx index f8edb9ff..71bb2206 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx @@ -75,6 +75,10 @@ const handlers: WoTableRowHandlers = { function renderUnscheduledHarness() { const tableData = { advActive: false, + unassigned: [], + unassignedError: null, + unassignedOpen: true, + setUnassignedOpen: vi.fn(), unscheduled: [unscheduledRow], unschedOpen: true, draftDayKey: null, diff --git a/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts new file mode 100644 index 00000000..bf8031e9 --- /dev/null +++ b/src/test/domain/work-orders/api/fetch-unassigned-board-rows.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { BOARD_SEARCH_MAX_PAGE_SIZE } from "@/domain/work-orders/utils/board-query-params"; + +const searchBoard = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + searchBoard: (...args: unknown[]) => searchBoard(...args), + }, +})); + +import { fetchUnassignedBoardRows } from "@/domain/work-orders/api/fetch-unassigned-board-rows"; + +function pageResult(items: Pick[], totalCount: number) { + return { + items, + scheduled: [], + unscheduled: [], + totalCount, + returnedCount: items.length, + page: 0, + pageSize: BOARD_SEARCH_MAX_PAGE_SIZE, + }; +} + +describe("fetchUnassignedBoardRows", () => { + beforeEach(() => { + searchBoard.mockReset(); + }); + + it("pages until totalCount is exhausted", async () => { + const first = Array.from({ length: BOARD_SEARCH_MAX_PAGE_SIZE }, (_, index) => ({ + id: `a-${index}`, + })); + const second = [{ id: "b-0" }]; + searchBoard + .mockResolvedValueOnce(pageResult(first, 101)) + .mockResolvedValueOnce(pageResult(second, 101)); + + const rows = await fetchUnassignedBoardRows(); + + expect(searchBoard).toHaveBeenCalledTimes(2); + expect(searchBoard.mock.calls[0]?.[0]).toMatchObject({ + page: 0, + dispatchers: ["__unassigned__"], + datePreset: "Custom", + }); + expect(searchBoard.mock.calls[1]?.[0]).toMatchObject({ page: 1 }); + expect(rows).toHaveLength(101); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 3fd01ec9..ba5b25ea 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -7,6 +7,9 @@ import { buildBoardSearchParamsRecord, toBoardQueryParams, toBoardSearchQueryParams, + toUnassignedQueueQueryParams, + UNASSIGNED_QUEUE_DATE_FROM, + UNASSIGNED_QUEUE_DATE_TO, } from "@/domain/work-orders/utils/board-query-params"; describe("toBoardQueryParams", () => { @@ -174,3 +177,21 @@ describe("buildBoardSearchParamsRecord", () => { expect(Object.keys(record)).not.toContain("types[]"); }); }); + +describe("toUnassignedQueueQueryParams", () => { + it("requests unassigned dispatchers with a Custom range, not the weekly board window", () => { + const params = toUnassignedQueueQueryParams(2); + const record = buildBoardSearchParamsRecord(params); + + expect(params.page).toBe(2); + expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE); + expect(params.dispatchers).toEqual(["__unassigned__"]); + expect(params.statuses).not.toContain(7); + expect(params.statuses).not.toContain(9); + expect(params.datePreset).toBe("Custom"); + expect(params.dateFrom).toBe(UNASSIGNED_QUEUE_DATE_FROM); + expect(params.dateTo).toBe(UNASSIGNED_QUEUE_DATE_TO); + expect(record).not.toHaveProperty("weekStart"); + expect(record).not.toHaveProperty("weekEnd"); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 7985c879..146aa7ac 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -4,11 +4,13 @@ import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-ad import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { buildDayGroups, + composeBoardSections, computeIsPastDue, enrichTableRow, filterByBarAndSearch, isUnassignedBoardRow, matchesAdvancedFilters, + mergeWeeklyAndUnassignedQueue, partitionBoardRows, splitScheduledUnscheduled, } from "@/domain/work-orders/utils/table-filter-utils"; @@ -184,6 +186,69 @@ describe("partitionBoardRows", () => { }); }); +describe("mergeWeeklyAndUnassignedQueue", () => { + it("keeps the weekly row when the same id is also in the queue", () => { + const weekly = makeRow({ id: "dup", dispatcherName: "Weekly" }); + const queue = makeRow({ id: "dup", dispatcherName: "Queue" }); + const extra = makeRow({ id: "out", dispatcherId: "", dispatcherName: "" }); + + const merged = mergeWeeklyAndUnassignedQueue([weekly], [queue, extra]); + expect(merged.map((row) => row.id)).toEqual(["dup", "out"]); + expect(merged[0]?.dispatcherName).toBe("Weekly"); + }); +}); + +describe("composeBoardSections", () => { + it("pins a scheduled unassigned WO that is outside the selected week", () => { + const inWeekAssigned = makeRow({ + id: "in-week", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "2026-06-03", + ...deriveDayFields("2026-06-03"), + }); + const outOfWeekUnassigned = makeRow({ + id: "out-week", + dispatcherId: "", + dispatcherName: "", + scheduledOn: "2026-07-15", + ...deriveDayFields("2026-07-15"), + }); + + const result = composeBoardSections({ + displayRows: [inWeekAssigned, outOfWeekUnassigned], + weekRows: [inWeekAssigned], + weeklyIds: new Set(["in-week"]), + queueIds: new Set(["out-week"]), + }); + + expect(result.unassigned.map((row) => row.id)).toEqual(["out-week"]); + expect(result.scheduled.map((row) => row.id)).toEqual(["in-week"]); + expect(result.unscheduled).toEqual([]); + }); + + it("does not place an assigned leftover queue row into the current week groups", () => { + const assignedOutOfWeek = makeRow({ + id: "was-queue", + dispatcherId: "sarah", + dispatcherName: "Sarah", + scheduledOn: "2026-07-15", + ...deriveDayFields("2026-07-15"), + }); + + const result = composeBoardSections({ + displayRows: [assignedOutOfWeek], + weekRows: [assignedOutOfWeek], + weeklyIds: new Set(), + queueIds: new Set(["was-queue"]), + }); + + expect(result.unassigned).toEqual([]); + expect(result.scheduled).toEqual([]); + expect(result.unscheduled).toEqual([]); + }); +}); + describe("splitScheduledUnscheduled", () => { it("splits rows without scheduledOn into unscheduled", () => { const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" }); From b60d0474bc505c5bac521ef06565fa88d4661c42 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 09:28:49 -0300 Subject: [PATCH 22/31] fix(work-orders): keep admin-approved revoke off the WO dialog (SH-214) SH-214/SH-212 own that action on Uplift Approvals; the WO surface only revokes the requester's auto-approved uplift. --- .../uplifts/work-order-uplift-list-item.tsx | 4 +-- .../work-order-uplifts-dialog-content.tsx | 3 -- .../uplifts/work-order-uplifts-dialog.tsx | 3 -- .../work-orders/utils/uplift-display-utils.ts | 19 ++++++------ .../work-order-uplifts-affordances.test.tsx | 31 +++++++++++++++++++ .../utils/uplift-display-utils.test.ts | 18 +++-------- 6 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx index 795209b0..0578302c 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx @@ -11,7 +11,6 @@ import { type WorkOrderUpliftListItemProps = { uplift: WorkOrderUplift; readOnly: boolean; - isAdmin: boolean; currentUserId: string | number | null | undefined; pendingAction?: boolean; onCancel?: () => void; @@ -21,7 +20,6 @@ type WorkOrderUpliftListItemProps = { export function WorkOrderUpliftListItem({ uplift, readOnly, - isAdmin, currentUserId, pendingAction = false, onCancel, @@ -30,7 +28,7 @@ export function WorkOrderUpliftListItem({ const pillStyle = getUpliftStatusPillStyle(uplift.status); const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); const showRevoke = - !readOnly && canRevokeWorkOrderUplift(uplift, currentUserId, isAdmin) && Boolean(onRevoke); + !readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke); return (
    diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx index d8548d82..b0c0c6a0 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx @@ -11,7 +11,6 @@ import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-displa type WorkOrderUpliftsDialogContentProps = { readOnly: boolean; - isAdmin: boolean; currentUserId: string | number | null | undefined; readOnlyStatusLabel?: string; isLoading: boolean; @@ -28,7 +27,6 @@ type WorkOrderUpliftsDialogContentProps = { export function WorkOrderUpliftsDialogContent({ readOnly, - isAdmin, currentUserId, readOnlyStatusLabel, isLoading, @@ -78,7 +76,6 @@ export function WorkOrderUpliftsDialogContent({ key={String(uplift.id)} uplift={uplift} readOnly={readOnly} - isAdmin={isAdmin} currentUserId={currentUserId} pendingAction={actionPending} onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)} diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx index 57de111a..7e6cbc27 100644 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx @@ -15,7 +15,6 @@ import { isWorkOrderUpliftsReadOnly, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; -import { isAdminUser } from "@/lib/auth/user-utils"; import { useAuthContext } from "@/providers/auth-context"; type RevokeTarget = { @@ -31,7 +30,6 @@ type WorkOrderUpliftsDialogProps = { export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) { const { user } = useAuthContext(); - const isAdmin = isAdminUser(user?.userRoles); const workOrderId = row?.id ?? null; const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true; const { @@ -79,7 +77,6 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD , currentUserId: string | number | null | undefined, - isAdmin: boolean, ): boolean { - if (uplift.status === "auto_approved") { - // SH-196: auto-approved revoke is dispatcher-own only (no admin bypass). - return ( - currentUserId != null && - currentUserId !== "" && - uplift.requestedById != null && - String(uplift.requestedById) === String(currentUserId) - ); + if (uplift.status !== "auto_approved") { + // SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog. + return false; } - return isAdmin && uplift.status === "approved"; + return ( + currentUserId != null && + currentUserId !== "" && + uplift.requestedById != null && + String(uplift.requestedById) === String(currentUserId) + ); } export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean { diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx index f6bf5dc3..101e0dd3 100644 --- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; +import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item"; import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; @@ -143,3 +144,33 @@ describe("WorkOrderUpliftsDialog affordances", () => { expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument(); }); }); + +describe("WorkOrderUpliftListItem revoke affordances", () => { + it("hides Revoke for admin-approved uplifts on the Work Order surface", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument(); + }); + + it("shows Revoke for the requesting dispatcher on auto-approved uplifts", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts index a05580f6..af91feeb 100644 --- a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -12,7 +12,6 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "dispatcher-1" }, "dispatcher-1", - false, ), ).toBe(true); }); @@ -22,7 +21,6 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "other-dispatcher" }, "dispatcher-1", - false, ), ).toBe(false); }); @@ -32,7 +30,6 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "dispatcher-1" }, "admin-user", - true, ), ).toBe(false); }); @@ -42,24 +39,19 @@ describe("canRevokeWorkOrderUplift", () => { canRevokeWorkOrderUplift( { status: "auto_approved", requestedById: "admin-user" }, "admin-user", - true, ), ).toBe(true); }); - it("allows approved revoke only for admin", () => { + it("rejects approved revoke on the Work Order surface (SH-214)", () => { const uplift = { status: "approved" as const, requestedById: "dispatcher-1" }; - expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", false)).toBe(false); - expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", true)).toBe(true); + expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1")).toBe(false); + expect(canRevokeWorkOrderUplift(uplift, "admin-user")).toBe(false); }); it("rejects revoke for other statuses", () => { - expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1", true)).toBe( - false, - ); - expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1", true)).toBe( - false, - ); + expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1")).toBe(false); + expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1")).toBe(false); }); }); From 7902476e2a5c596c9c7c65872cb093daafc26e9e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 18 Aug 2026 10:27:45 -0300 Subject: [PATCH 23/31] test(work-orders): cover remaining SH-121 facets and document BE producer on dev --- docs/work-orders/board-search-api.md | 9 ++++ ...ork-orders-advanced-search-wiring.test.tsx | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/docs/work-orders/board-search-api.md b/docs/work-orders/board-search-api.md index 6e7134f3..a190016e 100644 --- a/docs/work-orders/board-search-api.md +++ b/docs/work-orders/board-search-api.md @@ -93,6 +93,15 @@ Allowed `upliftStatuses`: `pending`, `approved`, `auto_approved`, `rejected` (ex Exemplo: pending uplifts only — `hasUplift=true&upliftStatuses=pending`. +## Backend producer (`shoc-backend` `dev`) + +These facet keys are handled on `GET /board/search` in `shoc-backend` `origin/dev` ([#45](https://github.com/Sea-Haven-Industries/shoc-backend/pull/45), [#65](https://github.com/Sea-Haven-Industries/shoc-backend/pull/65)). There is no frontend deployment fence. + +- `IsAddOn` is owned by migration `20260730150000_WoIsAddOn` (legacy type-7 backfill). +- `20260813193000_SH121_BoardSearchFacets` is a **no-op** so both PRs can land without a duplicate column. +- Pagination matches this contract: `page` is 0-based; `pageSize` default/max is 100. +- Aveta (`avetaOnly`) remains omitted until `avetaRequired` is confirmed. + ### `datePreset` | FE key | API value | diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 09475faf..197dd309 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -197,4 +197,47 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(params.hasUplift).toBe(true); expect(params.upliftStatuses).toEqual(["pending"]); }); + + it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /Indicators/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /Rescheduled ≥ 2/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /Carried Over ≥ 2/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /^Add-On$/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /^Flag$/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /^Red$/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /Work Order Number/i })); + fireEvent.click(within(sheet).getByRole("button", { name: /Internal numbers only/i })); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + + expect(applyAdvancedFilters).toHaveBeenCalled(); + const appliedCalls = applyAdvancedFilters.mock.calls; + const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS; + expect(applied.rescheduled).toBe(true); + expect(applied.carriedOver).toBe(true); + expect(applied.addOn).toBe(true); + expect(applied.flagColors.has("#EF4444")).toBe(true); + expect(applied.internalOnly).toBe(true); + + const params = toBoardSearchQueryParams({ filters: applied }); + expect(params.page).toBe(0); + expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE); + expect(params.rescheduled).toBe(true); + expect(params.carriedOver).toBe(true); + expect(params.addOn).toBe(true); + expect(params.flagColors).toEqual(["#EF4444"]); + expect(params.internalOnly).toBe(true); + }); }); From 0966962d8f662fb410c7fa49d58b5381524aeb6e Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 11:50:46 -0300 Subject: [PATCH 24/31] fix(work-orders): keep Unassigned pin visible when empty Render the weekly Unassigned section at count 0 and cover assignment through the board PATCH path. --- .../_components/list/table/wo-table-body.tsx | 57 ++++---- .../wo-table-unassigned-queue.test.tsx | 125 ++++++++++++++++++ 2 files changed, 155 insertions(+), 27 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index 5b2d5b38..bfbde81a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -20,40 +20,43 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo return {tableData.advResults.map((row) => renderRow(row))}; } - const showUnassigned = - (tableData.unassigned?.length ?? 0) > 0 || - tableData.draftDayKey === UNASSIGNED_DAY_KEY || - Boolean(tableData.unassignedError); + const unassignedRows = tableData.unassigned ?? []; const showUnscheduled = (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + const showUnassignedEmpty = + tableData.unassignedOpen && unassignedRows.length === 0 && !tableData.unassignedError; return ( - {showUnassigned && ( + tableData.setUnassignedOpen((v) => !v)} + /> + {tableData.unassignedOpen && ( <> - tableData.setUnassignedOpen((v) => !v)} - /> - {tableData.unassignedOpen && ( - <> - {renderInlineRow(UNASSIGNED_DAY_KEY)} - {Boolean(tableData.unassignedError) && ( - - - - {tableData.unassignedError?.message ?? - "Unable to load unassigned work orders."} - - - - )} - {(tableData.unassigned ?? []).map((row) => renderRow(row))} - + {renderInlineRow(UNASSIGNED_DAY_KEY)} + {Boolean(tableData.unassignedError) && ( + + + + {tableData.unassignedError?.message ?? "Unable to load unassigned work orders."} + + + )} + {showUnassignedEmpty && ( + + + + No unassigned work orders. + + + + )} + {unassignedRows.map((row) => renderRow(row))} )} {showUnscheduled && ( diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index a5a371b0..d92553a0 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -1,9 +1,17 @@ +import { useMemo, useState } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell"; import { WoTableBody } from "@/app/(protected)/workorders/_components/list/table/wo-table-body"; import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types"; +import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + buildDayGroups, + composeBoardSections, + rowIdSet, +} from "@/domain/work-orders/utils/table-filter-utils"; function makeRow(overrides: Partial = {}): WorkOrderTableRow { return { @@ -102,6 +110,72 @@ function baseTableData( } as ReturnTypeOfUseWorkOrdersTableData; } +const DISPATCHER = { id: "sarah", name: "Sarah Chen", initials: "SC", color: "#3366cc" }; + +function UnassignedAssignHarness({ + initialRow, + patchBoardField, +}: { + initialRow: WorkOrderTableRow; + patchBoardField: ( + id: string | number, + payload: { field: string; value?: string | null }, + ) => Promise; +}) { + const [rows, setRows] = useState([initialRow]); + const weeklyIds = rowIdSet(rows); + const sections = useMemo( + () => + composeBoardSections({ + displayRows: rows, + weekRows: rows, + weeklyIds, + queueIds: weeklyIds, + }), + [rows, weeklyIds], + ); + const tableData = baseTableData({ + unassigned: sections.unassigned, + unscheduled: sections.unscheduled, + displayGroups: buildDayGroups(sections.scheduled), + }); + + return ( + + ( + + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber} + { + const ops = expandTablePatchToBoardFieldOps({ + dispatcherId: id, + dispatcherName: name, + }); + const patched = await patchBoardField(row.id, ops[0]!); + setRows((current) => + current.map((item) => + String(item.id) === String(row.id) + ? { ...patched, dispatcherId: id, dispatcherName: name } + : item, + ), + ); + }} + /> +
    + ); +} + describe("WoTableBody Unassigned queue (SH-165)", () => { it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => { const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" }); @@ -252,4 +326,55 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { expect(screen.getByText("queue unavailable")).toBeInTheDocument(); }); + + it("keeps the Unassigned section visible with an empty state when the queue is empty", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber}
    , + ); + + expect( + screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }), + ).toBeInTheDocument(); + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument(); + }); + + it("removes a pinned row immediately after assigning through the board PATCH path", async () => { + const user = userEvent.setup(); + const pinned = makeRow({ + id: "u-pin", + woNumber: "24991", + dispatcherId: "", + dispatcherName: "", + rowVersion: "AAAAAAA=", + }); + const patchBoardField = vi.fn(async (_id: string | number, payload: { field: string }) => { + expect(payload.field).toBe("assignTo"); + return { ...pinned, dispatcherId: DISPATCHER.id, dispatcherName: DISPATCHER.name }; + }); + + render(); + + expect(screen.getByTestId("row-u-pin")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Select/ })); + await user.click(screen.getByRole("button", { name: /Sarah Chen/ })); + + expect(patchBoardField).toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Unassigned, 0 work orders, expanded" }), + ).toBeInTheDocument(); + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.getByTestId("row-u-pin")).toBeInTheDocument(); + expect(screen.getByText("TUE · 06/02")).toBeInTheDocument(); + }); }); From 32177e831b9eceaeb1fb21539839abcf4255d5e8 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 12:04:55 -0300 Subject: [PATCH 25/31] fix(work-orders): set isAddOn on pending-uplift StatusCell fixture Keep the SH-188 fixture assignable to WorkOrderTableRow after the SH-184 merge. --- .../(protected)/workorders/status-cell-pending-uplift.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx index 6bc04e34..d886232a 100644 --- a/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx +++ b/src/test/app/(protected)/workorders/status-cell-pending-uplift.test.tsx @@ -42,6 +42,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow originalDate: "", isPastDue: false, scheduleWeekOnly: false, + isAddOn: false, mediaCount: 1, title: "", priority: "", From ac9ce341016990720d3a19f72370fc3d293132cd Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 17:22:32 -0300 Subject: [PATCH 26/31] fix(work-orders): distinguish loading from empty in Unassigned pin (SH-165) The pinned Unassigned section folded the queue query's pending state into isFetching only, which the section never read. On first mount, once GET /board resolved while the queue was still paging GET /board/search, the pin announced "No unassigned work orders." instead of a loading state. Expose the queue query's own isLoading as unassignedLoading, thread it through the table data hook, and render an aria-busy loading row while it is true so the loading and empty states are separately observable. --- .../_components/list/table/wo-table-body.tsx | 22 ++++++++++++- .../_hooks/use-work-orders-board-source.ts | 6 ++++ .../_hooks/use-work-orders-table-data.ts | 2 ++ .../wo-table-unassigned-queue.test.tsx | 31 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index bfbde81a..7e51c960 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -23,8 +23,19 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo const unassignedRows = tableData.unassigned ?? []; const showUnscheduled = (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + // SH-165: "still loading" and "zero unassigned" are distinct states. Without + // this split the pin announced "No unassigned work orders." while the queue + // was still paging board/search on first mount. + const showUnassignedLoading = + tableData.unassignedOpen && + unassignedRows.length === 0 && + !tableData.unassignedError && + tableData.unassignedLoading; const showUnassignedEmpty = - tableData.unassignedOpen && unassignedRows.length === 0 && !tableData.unassignedError; + tableData.unassignedOpen && + unassignedRows.length === 0 && + !tableData.unassignedError && + !tableData.unassignedLoading; return ( @@ -47,6 +58,15 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo )} + {showUnassignedLoading && ( + + + + Loading unassigned work orders… + + + + )} {showUnassignedEmpty && ( 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 11771ca7..914bf835 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 @@ -23,6 +23,7 @@ export type WorkOrdersBoardSource = { data: WorkOrderBoardListResult | undefined; unassignedQueue: WorkOrderTableRow[]; unassignedError: Error | null; + unassignedLoading: boolean; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -75,6 +76,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); const { data: queueData, + isLoading: queueLoading, isFetching: queueFetching, error: queueError, refetch: refetchQueue, @@ -94,6 +96,10 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar data: advActive ? searchData : boardData, unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE), unassignedError: advActive ? null : (queueError ?? null), + // SH-165: the pinned queue has its own first-load state. Folding it into + // isFetching only made "still paging board/search" indistinguishable from + // "zero unassigned", so the pin announced an empty queue while loading. + unassignedLoading: advActive ? false : queueLoading, isLoading: advActive ? searchLoading : boardLoading, isFetching: advActive ? searchFetching : boardFetching || queueFetching, error: advActive ? searchError : boardError, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 469fd465..752272de 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -26,6 +26,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { data, unassignedQueue, unassignedError, + unassignedLoading, isLoading, isFetching, error, @@ -107,6 +108,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advResults, unassigned, unassignedError, + unassignedLoading, unscheduled, displayGroups, searchActive, diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index d010346d..66a61208 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -76,6 +76,7 @@ function baseTableData( advResults: [], unassigned: [], unassignedError: null, + unassignedLoading: false, unscheduled: [], displayGroups: [], searchActive: false, @@ -308,6 +309,36 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { expect(screen.queryByText("WED · 07/15")).not.toBeInTheDocument(); }); + it("distinguishes a still-loading queue from an empty one", () => { + render( + + null} + renderInlineRow={() => null} + /> +
    , + ); + + expect(screen.getByText(/Loading unassigned work orders/)).toBeInTheDocument(); + expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument(); + }); + + it("announces an empty queue only once loading has settled", () => { + render( + + null} + renderInlineRow={() => null} + /> +
    , + ); + + expect(screen.getByText("No unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument(); + }); + it("keeps the Unassigned pin visible when the queue query fails", () => { render( From a109a68f40524b95be20d487ade76d140bcaa13d Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 17:28:14 -0300 Subject: [PATCH 27/31] fix(work-orders): keep SH-188 tooltip when mixed with other gaps Stop copying WO lifecycle status into uplift primaryStatus on flat board rows. --- .../work-orders/mappers/work-order-table-row-mapper.ts | 4 +++- src/domain/work-orders/utils/wo-closability.ts | 2 +- .../work-orders/mappers/work-order-mapper.test.ts | 10 ++++++++++ .../domain/work-orders/utils/wo-closability.test.ts | 10 ++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 0442d420..41ee9924 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -239,7 +239,9 @@ function readUpliftSummary(item: Record): WorkOrderUpliftSummar hasUplift: hasUplift || pendingCount > 0, pendingCount, }; - const primaryStatus = readString(source, "primaryStatus", "PrimaryStatus", "status", "Status"); + const primaryStatus = + readString(nested, "primaryStatus", "PrimaryStatus") || + readString(item, "primaryStatus", "PrimaryStatus"); if (primaryStatus) { summary.primaryStatus = primaryStatus; } diff --git a/src/domain/work-orders/utils/wo-closability.ts b/src/domain/work-orders/utils/wo-closability.ts index 024a1ca5..be377091 100644 --- a/src/domain/work-orders/utils/wo-closability.ts +++ b/src/domain/work-orders/utils/wo-closability.ts @@ -109,7 +109,7 @@ export function getClosabilityGaps(row: WoClosabilityInput): string[] { } export function formatClosabilityMissingTitle(gaps: string[]): string { - if (gaps.length === 1 && gaps[0] === PENDING_UPLIFT_GAP) { + if (gaps.includes(PENDING_UPLIFT_GAP)) { return UPLIFT_PENDING_COMPLETED_TITLE; } return `Missing: ${gaps.join(", ")}`; diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts index 76d4af5c..107d63f0 100644 --- a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -209,6 +209,16 @@ describe("mapWorkOrderTableRow (board DTO)", () => { expect(clear.upliftSummary).toEqual({ hasUplift: false, pendingCount: 0 }); }); + it("does not copy work-order status into uplift primaryStatus on flat board rows", () => { + const row = mapWorkOrderTableRow({ + id: 308, + pendingUpliftCount: 1, + status: "In Progress", + Status: "In Progress", + }); + expect(row.upliftSummary?.primaryStatus).toBeUndefined(); + }); + it("leaves hasPendingUplift undefined when board count is absent", () => { const row = mapWorkOrderTableRow({ id: 307 }); expect(row.hasPendingUplift).toBeUndefined(); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 7c761560..8af2724e 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -128,9 +128,15 @@ describe("formatClosabilityMissingTitle", () => { ); }); - it("joins multiple gaps with Missing prefix", () => { + it("uses design Completed title when pending uplift is among mixed gaps", () => { expect(formatClosabilityMissingTitle(["Dispatcher", PENDING_UPLIFT_GAP])).toBe( - `Missing: Dispatcher, ${PENDING_UPLIFT_GAP}`, + UPLIFT_PENDING_COMPLETED_TITLE, + ); + }); + + it("joins multiple non-uplift gaps with Missing prefix", () => { + expect(formatClosabilityMissingTitle(["Dispatcher", "Company"])).toBe( + "Missing: Dispatcher, Company", ); }); }); From 358495cfe9a6eb5e31273616df7f2baf5497a352 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 17:40:16 -0300 Subject: [PATCH 28/31] fix(work-orders): distinguish unassigned loading from empty pin --- .../_components/list/table/wo-table-body.tsx | 16 ++++++++++++++- .../_hooks/use-work-orders-board-source.ts | 3 +++ .../_hooks/use-work-orders-table-data.ts | 2 ++ .../wo-table-unassigned-queue.test.tsx | 20 +++++++++++++++++++ .../wo-table-unscheduled-highlight.test.tsx | 1 + 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx index bfbde81a..65f49dbd 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx @@ -23,8 +23,13 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo const unassignedRows = tableData.unassigned ?? []; const showUnscheduled = (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY; + const showUnassignedLoading = + tableData.unassignedOpen && Boolean(tableData.unassignedLoading) && !tableData.unassignedError; const showUnassignedEmpty = - tableData.unassignedOpen && unassignedRows.length === 0 && !tableData.unassignedError; + tableData.unassignedOpen && + unassignedRows.length === 0 && + !tableData.unassignedError && + !tableData.unassignedLoading; return ( @@ -47,6 +52,15 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo )} + {showUnassignedLoading && ( + + + + )} {showUnassignedEmpty && (
    + + Loading unassigned work orders. + +
    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 11771ca7..2227e3f9 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 @@ -23,6 +23,7 @@ export type WorkOrdersBoardSource = { data: WorkOrderBoardListResult | undefined; unassignedQueue: WorkOrderTableRow[]; unassignedError: Error | null; + unassignedLoading: boolean; isLoading: boolean; isFetching: boolean; error: Error | null; @@ -75,6 +76,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar } = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive); const { data: queueData, + isPending: queuePending, isFetching: queueFetching, error: queueError, refetch: refetchQueue, @@ -94,6 +96,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar data: advActive ? searchData : boardData, unassignedQueue: advActive ? EMPTY_UNASSIGNED_QUEUE : (queueData ?? EMPTY_UNASSIGNED_QUEUE), unassignedError: advActive ? null : (queueError ?? null), + unassignedLoading: advActive ? false : queuePending, isLoading: advActive ? searchLoading : boardLoading, isFetching: advActive ? searchFetching : boardFetching || queueFetching, error: advActive ? searchError : boardError, diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts index 469fd465..752272de 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-data.ts @@ -26,6 +26,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { data, unassignedQueue, unassignedError, + unassignedLoading, isLoading, isFetching, error, @@ -107,6 +108,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) { advResults, unassigned, unassignedError, + unassignedLoading, unscheduled, displayGroups, searchActive, diff --git a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx index d010346d..a3461cd9 100644 --- a/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unassigned-queue.test.tsx @@ -76,6 +76,7 @@ function baseTableData( advResults: [], unassigned: [], unassignedError: null, + unassignedLoading: false, unscheduled: [], displayGroups: [], searchActive: false, @@ -350,6 +351,25 @@ describe("WoTableBody Unassigned queue (SH-165)", () => { expect(screen.queryByTestId(/row-/)).not.toBeInTheDocument(); }); + it("shows a loading state instead of empty while the unassigned queue is pending", () => { + render( + + ( + + + + )} + renderInlineRow={() => null} + /> +
    {row.woNumber}
    , + ); + + expect(screen.getByText("Loading unassigned work orders.")).toBeInTheDocument(); + expect(screen.queryByText("No unassigned work orders.")).not.toBeInTheDocument(); + }); + it("removes a pinned row immediately after assigning through the board PATCH path", async () => { const user = userEvent.setup(); const pinned = makeRow({ diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx index cc1cb93e..9a5216c8 100644 --- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx @@ -78,6 +78,7 @@ function renderUnscheduledHarness() { advActive: false, unassigned: [], unassignedError: null, + unassignedLoading: false, unassignedOpen: true, setUnassignedOpen: vi.fn(), unscheduled: [unscheduledRow], From bea2858c81bd88a1404056d42857914fed18b89d Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 17:40:47 -0300 Subject: [PATCH 29/31] fix(work-orders): search all weeks when advanced filters are applied --- .../list/filter-bar/dispatcher-filter.tsx | 3 ++ .../_components/list/filter-bar/week-nav.tsx | 10 ++-- .../list/filter-bar/wo-type-segment.tsx | 11 ++++- .../filter-bar/work-orders-filter-bar.tsx | 12 +++-- src/app/(protected)/workorders/index.tsx | 1 + .../work-orders/utils/board-query-params.ts | 49 ++++++++++++++----- ...ork-orders-advanced-search-wiring.test.tsx | 24 +++++++-- .../utils/board-query-params.test.ts | 9 ++-- 8 files changed, 92 insertions(+), 27 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx index 9db6ca98..6bee3519 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter.tsx @@ -14,6 +14,7 @@ type DispatcherFilterProps = { users: WorkOrderUser[]; usersLoading?: boolean; currentUserId?: string | number; + disabled?: boolean; }; function userInitials(name: string): string { @@ -40,6 +41,7 @@ export function DispatcherFilter({ users, usersLoading = false, currentUserId, + disabled = false, }: DispatcherFilterProps) { const { anchorEl, open, setAnchor, close } = useMenuAnchor(); @@ -86,6 +88,7 @@ export function DispatcherFilter({ void; + + disabled?: boolean; }; const VISIBLE_COUNT = 3; @@ -51,7 +53,7 @@ function WeekLiveBadge() { ); } -export function WeekNav({ weekMonday, onChange }: WeekNavProps) { +export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) { const weeks = useMemo( () => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER), @@ -91,7 +93,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) { return (
    setOffset((current) => current - 1)} aria-label="Previous weeks" > @@ -109,7 +111,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) { onChange(week.mondayIso)} > {week.label} @@ -120,7 +122,7 @@ export function WeekNav({ weekMonday, onChange }: WeekNavProps) { })} setOffset((current) => current + 1)} aria-label="Next weeks" > diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx index 3810f8ec..03d89520 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment.tsx @@ -8,15 +8,22 @@ type WoTypeSegmentProps = { value: WorkOrderTypeFilter; onChange: (value: WorkOrderTypeFilter) => void; + + disabled?: boolean; }; const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES]; -export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) { +export function WoTypeSegment({ value, onChange, disabled = false }: WoTypeSegmentProps) { return ( {OPTIONS.map((option) => ( - onChange(option)}> + onChange(option)} + > {option} ))} diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx index d12819f5..3a1c114f 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx @@ -16,6 +16,7 @@ type WorkOrdersFilterBarProps = { currentUserId?: string | number; filteredCount: number; scopeTotalCount: number; + disabled?: boolean; }; export function WorkOrdersFilterBar({ @@ -30,20 +31,25 @@ export function WorkOrdersFilterBar({ currentUserId, filteredCount, scopeTotalCount, + disabled = false, }: WorkOrdersFilterBarProps) { const resultLabel = `${filteredCount} of ${scopeTotalCount}`; return ( -
    +
    - - + +
    {resultLabel}
    diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 9036de10..066cd167 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -49,6 +49,7 @@ export default function WorkOrdersListPage() { currentUserId={user?.id} filteredCount={tableData.totalCount} scopeTotalCount={tableData.scopeTotalCount} + disabled={filters.advApplied != null} /> {filters.advApplied != null && ( diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index ba8d6701..36592b05 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -1,5 +1,8 @@ import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; -import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { BOARD_DISPATCHER_UNASSIGNED, @@ -16,6 +19,10 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord export const BOARD_SEARCH_MAX_PAGE_SIZE = 100; +/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */ +export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01"; +export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31"; + function toBoardDispatcherId(id: string): string { if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) { return BOARD_DISPATCHER_UNASSIGNED; @@ -136,6 +143,31 @@ export function toBoardQueryParams( }; } +function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { + datePreset: WorkOrderBoardSearchQueryParams["datePreset"]; + dateFrom?: string; + dateTo?: string; +} { + if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) { + return { + datePreset: "Custom", + dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, + dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + }; + } + + const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange]; + if (datePreset === "Custom") { + return { + datePreset, + dateFrom: filters.customFrom || undefined, + dateTo: filters.customTo || undefined, + }; + } + + return { datePreset }; +} + /** Build GET /board/search query from advanced filters (+ optional bar/search). */ export function toBoardSearchQueryParams(input: { filters: WorkOrderAdvancedFilters; @@ -148,7 +180,7 @@ export function toBoardSearchQueryParams(input: { vendorIdsByCompanyName?: Map; }): WorkOrderBoardSearchQueryParams { const { filters } = input; - const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange]; + const date = resolveAdvancedSearchDate(filters); const pageSize = Math.min( input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE, @@ -158,11 +190,13 @@ export function toBoardSearchQueryParams(input: { const trimmedSearch = input.search?.trim(); const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; - const params: WorkOrderBoardSearchQueryParams = { + return { page: input.page ?? 0, pageSize, search, - datePreset, + datePreset: date.datePreset, + dateFrom: date.dateFrom, + dateTo: date.dateTo, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, types: typeFilters.types, overdue: typeFilters.overdue, @@ -175,13 +209,6 @@ export function toBoardSearchQueryParams(input: { sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", }; - - if (datePreset === "Custom") { - params.dateFrom = filters.customFrom || undefined; - params.dateTo = filters.customTo || undefined; - } - - return params; } /** ky/URLSearchParams-friendly object; arrays become repeated keys. */ diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 197dd309..19bca5be 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -106,10 +106,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ WoTable: () => null, })); -vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({ - WorkOrdersFilterBar: () => null, -})); - vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({ MediaUploadModal: () => null, })); @@ -196,6 +192,9 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(params.severities).toEqual([1]); expect(params.hasUplift).toBe(true); expect(params.upliftStatuses).toEqual(["pending"]); + expect(params.datePreset).toBe("Custom"); + expect(params.dateFrom).toBe("2000-01-01"); + expect(params.dateTo).toBe("2099-12-31"); }); it("applies Indicators, Flag, and Internal WO# through the public Advanced Filters sheet", () => { @@ -239,5 +238,22 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(params.addOn).toBe(true); expect(params.flagColors).toEqual(["#EF4444"]); expect(params.internalOnly).toBe(true); + expect(params.datePreset).toBe("Custom"); + expect(params.dateFrom).toBe("2000-01-01"); + expect(params.dateTo).toBe("2099-12-31"); + }); + + it("disables dispatcher, week, and type bar while advanced filters are applied", () => { + advApplied = { ...EMPTY_ADVANCED_FILTERS, rescheduled: true }; + renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "All" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "PM" })).toBeDisabled(); }); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 31461891..53f79b3b 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -65,7 +65,9 @@ describe("toBoardSearchQueryParams", () => { }); expect(params.page).toBe(0); expect(params.pageSize).toBe(BOARD_SEARCH_MAX_PAGE_SIZE); - expect(params.datePreset).toBe("ThisWeek"); + expect(params.datePreset).toBe("Custom"); + expect(params.dateFrom).toBe("2000-01-01"); + expect(params.dateTo).toBe("2099-12-31"); }); it("maps date presets to PascalCase API values", () => { @@ -96,8 +98,9 @@ describe("toBoardSearchQueryParams", () => { customTo: "2026-06-30", }, }); - expect(thisWeek.dateFrom).toBeUndefined(); - expect(thisWeek.dateTo).toBeUndefined(); + expect(thisWeek.datePreset).toBe("Custom"); + expect(thisWeek.dateFrom).toBe("2000-01-01"); + expect(thisWeek.dateTo).toBe("2099-12-31"); }); it("maps Overdue-only advanced filter to overdue=true without types", () => { From 2554e40fd8627f83de5f85fc94e575bcd66f6675 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 18:13:43 -0300 Subject: [PATCH 30/31] test(work-orders): assert all-weeks total and Clear All restores the bar --- .../work-orders-advanced-search-wiring.test.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 19bca5be..cffbaedf 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -59,8 +59,8 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ useWorkOrdersTableData: () => ({ rows: [], - totalCount: 0, - scopeTotalCount: 0, + totalCount: advApplied ? 42 : 0, + scopeTotalCount: advApplied ? 42 : 0, isFetching: false, isLoading: false, setSelectedId: vi.fn(), @@ -245,7 +245,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { it("disables dispatcher, week, and type bar while advanced filters are applied", () => { advApplied = { ...EMPTY_ADVANCED_FILTERS, rescheduled: true }; - renderWithProviders(, { + const view = renderWithProviders(, { route: "/workorders", withAuth: false, }); @@ -255,5 +255,16 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled(); expect(screen.getByRole("button", { name: "All" })).toBeDisabled(); expect(screen.getByRole("button", { name: "PM" })).toBeDisabled(); + expect(screen.getByText("42 work orders")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /clear all/i })); + expect(clearAdvancedFilters).toHaveBeenCalled(); + + view.rerender(); + + expect(screen.queryByText("42 work orders")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All dispatchers" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "All" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "PM" })).toBeEnabled(); }); }); From 93df28335f44e00047932a84ba49039b1cacc9e5 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Tue, 18 Aug 2026 21:44:55 -0300 Subject: [PATCH 31/31] fix(work-orders): keep SH-196 uplift mapping typed after merging origin/dev --- .../work-orders/mappers/work-order-table-row-mapper.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts index 41ee9924..f845f009 100644 --- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts @@ -1,4 +1,5 @@ import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper"; import { DOC_STATUS_API, LIFECYCLE_STATUS_API_TO_FE, @@ -319,8 +320,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow { const locationIdRaw = item.locationId ?? item.LocationId; const vendorIdNum = readNumber(item, "vendorId", "VendorId"); const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId"); - const upliftSummary = readUpliftSummary(item); - const hasPendingUplift = readHasPendingUplift(item, upliftSummary); + const boardUpliftSummary = readUpliftSummary(item); + const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary); + const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item); const row: WorkOrderTableRow = { id: readId(item),