From 20237f8142f948eab63f2b2034dd42114dd8cc24 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 15:23:03 -0300 Subject: [PATCH 01/10] feat(vendors): align directory UI with current prototype (#46) * feat(vendors): align directory UI with prototype * fix(vendors): report the filtered total * fix(vendors): keep company input in sync * fix(vendors): scope facets to directory status --- e2e/vendors/vendors.spec.ts | 335 +++++++++++++ src/api/api-paths.ts | 1 + .../_components/vendor-create-modal.tsx | 146 ++++++ .../_components/vendor-detail-drawer.tsx | 466 ++++++++++++++++++ .../_components/vendor-filters-drawer.tsx | 316 ++++++++++++ .../vendors/_components/vendor-filters.ts | 13 + .../_components/vendor-form-fields.tsx | 340 +++++++++++++ .../vendors/_components/vendor-form.tsx | 112 ++--- src/app/(protected)/vendors/index.tsx | 455 +++++++++++++---- src/domain/vendors/api/vendors-api.ts | 43 +- src/domain/vendors/mappers/vendor-mapper.ts | 91 +++- src/domain/vendors/schemas/vendor-schema.ts | 18 +- src/domain/vendors/types/vendor.ts | 44 ++ .../vendors/use-cases/use-create-vendor.ts | 3 - .../vendors/use-cases/use-delete-vendor.ts | 4 +- .../vendors/use-cases/use-update-vendor.ts | 3 - .../vendors/use-cases/use-vendor-facets.ts | 13 + src/infra/query-key/query-key.ts | 1 + .../vendors/vendor-form-fields.test.tsx | 151 ++++++ .../(protected)/vendors/vendors-list.test.tsx | 86 ++++ .../domain/vendors/api/vendors-api.test.ts | 151 ++++++ .../vendors/schemas/vendor-schema.test.ts | 36 ++ .../use-cases/use-vendor-facets.test.tsx | 86 ++++ 23 files changed, 2722 insertions(+), 192 deletions(-) create mode 100644 e2e/vendors/vendors.spec.ts create mode 100644 src/app/(protected)/vendors/_components/vendor-create-modal.tsx create mode 100644 src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx create mode 100644 src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx create mode 100644 src/app/(protected)/vendors/_components/vendor-filters.ts create mode 100644 src/app/(protected)/vendors/_components/vendor-form-fields.tsx create mode 100644 src/domain/vendors/use-cases/use-vendor-facets.ts create mode 100644 src/test/app/(protected)/vendors/vendor-form-fields.test.tsx create mode 100644 src/test/app/(protected)/vendors/vendors-list.test.tsx create mode 100644 src/test/domain/vendors/api/vendors-api.test.ts create mode 100644 src/test/domain/vendors/schemas/vendor-schema.test.ts create mode 100644 src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts new file mode 100644 index 00000000..e7c42c26 --- /dev/null +++ b/e2e/vendors/vendors.spec.ts @@ -0,0 +1,335 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "vendor-e2e-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Vendor E2E Admin", + id: "1", +}; + +const activeVendors = [ + { + Id: 1, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", + CompanyPhone: "314-555-0100", + Address: "1 Market St", + City: "St. Louis", + State: "MO", + Zip: "63101", + TradeSpecialties: "Plumbing, Backflow Preventers", + GoogleMapsUrl: "https://maps.google.com/gateway", + Notes: "Preferred after-hours vendor", + TotalJobs: 72, + IsActive: true, + }, + { + Id: 2, + CompanyName: "Metro HVAC", + ContactName: "Maya Chen", + Email: "maya@metro.test", + Phone: "314-555-0112", + CompanyPhone: "314-555-0110", + Address: "2 Pine St", + City: "Clayton", + State: "MO", + Zip: "63105", + TradeSpecialties: "HVAC", + GoogleMapsUrl: "", + Notes: "", + TotalJobs: 18, + IsActive: true, + }, +]; + +const inactiveVendors = [ + { + ...activeVendors[0], + Id: 3, + CompanyName: "Drain Cleaning Bros", + ContactName: "Drew Banks", + TotalJobs: 151, + IsActive: false, + }, +]; + +interface MockState { + listUrls: string[]; + createdBody?: Record; + updatedBody?: Record; + deletedId?: string; +} + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function captureVisual(page: Page, path: string) { + if (!process.env.VENDOR_VISUAL_CAPTURE) return; + await page.waitForTimeout(400); + await page.screenshot({ path, fullPage: true }); +} + +async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) { + const state: MockState = { listUrls: [] }; + + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + + await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) => + fulfillJson(route, { + data: [ + { id: 1, value: "Plumbing" }, + { id: 2, value: "HVAC" }, + ], + }), + ); + await page.route("**/api/Vendor/facets*", (route) => + fulfillJson(route, { + data: { + companies: activeVendors.map((vendor) => ({ + name: vendor.CompanyName, + companyPhone: vendor.CompanyPhone, + email: vendor.Email, + address: vendor.Address, + city: vendor.City, + state: vendor.State, + zip: vendor.Zip, + googleMapsUrl: vendor.GoogleMapsUrl, + })), + trades: ["Plumbing", "Backflow Preventers", "HVAC"], + locations: [ + { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, + { city: "Clayton", state: "MO", label: "Clayton, MO" }, + ], + jobBuckets: [ + { id: "under-50", label: "Under 50" }, + { id: "50-99", label: "50–99" }, + { id: "100-149", label: "100–149" }, + { id: "150-plus", label: "150+" }, + ], + }, + }), + ); + await page.route("**/api/Vendor/GetVendorList*", async (route) => { + const url = new URL(route.request().url()); + state.listUrls.push(url.toString()); + if (options.listError && url.searchParams.get("pageSize") !== "1") { + await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); + return; + } + + const isActive = url.searchParams.get("isActive") !== "false"; + const source = isActive ? activeVendors : inactiveVendors; + const search = (url.searchParams.get("search") ?? "").toLowerCase(); + const company = url.searchParams.get("companies[0]"); + const filtered = source.filter( + (vendor) => + (!search || + `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}` + .toLowerCase() + .includes(search)) && + (!company || vendor.CompanyName === company), + ); + const isCount = url.searchParams.get("pageSize") === "1"; + await fulfillJson(route, { + Data: isCount ? filtered.slice(0, 1) : filtered, + TotalCount: filtered.length, + PageNumber: 1, + PageSize: Number(url.searchParams.get("pageSize") ?? 12), + }); + }); + await page.route(/\/api\/vendors\/\d+$/, async (route) => { + const id = route.request().url().split("/").pop() ?? ""; + if (route.request().method() === "PUT") { + state.updatedBody = route.request().postDataJSON(); + await fulfillJson(route, { + data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) }, + }); + return; + } + if (route.request().method() === "DELETE") { + state.deletedId = id; + await fulfillJson(route, { status: "200" }); + return; + } + const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id); + await fulfillJson(route, { data: vendor }); + }); + await page.route(/\/api\/vendors\/?$/, async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + state.createdBody = route.request().postDataJSON(); + await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } }); + }); + + return state; +} + +test.describe("Vendor directory prototype parity", () => { + test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ + page, + }) => { + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); + await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( + "aria-selected", + "true", + ); + for (const heading of [ + "Company", + "Technician", + "Location", + "Trade", + "Phone", + "Email", + "Total Jobs", + "Status", + ]) { + await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); + } + await expect(page.getByText("Gateway Plumbing")).toBeVisible(); + await expect(page.getByText("+1")).toBeVisible(); + await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); + + await page.getByPlaceholder("Search name, company, trade...").fill("metro"); + await expect(page.getByText("Metro HVAC")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( + true, + ); + + 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 expect(page.getByText("Gateway Plumbing").first()).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 page.getByRole("button", { name: "Clear all" }).click(); + await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); + }); + + test("creates with company autofill, then views and edits every detail field", async ({ + page, + }) => { + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + await page.getByRole("button", { name: "+ Add Vendor" }).click(); + await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await page.getByRole("button", { name: "Add Vendor" }).last().click(); + await expect(page.getByText("Technician name is required")).toBeVisible(); + await expect(page.getByText("Company is required")).toBeVisible(); + + await page.getByLabel("Technician Name").fill("New Technician"); + 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.getByLabel("Email")).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 page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing"); + await page.getByRole("button", { name: "Add trade" }).click(); + await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC"); + await page.getByRole("button", { name: "Add trade" }).click(); + await page.getByRole("button", { name: "Move HVAC up" }).click(); + await page.getByLabel("Notes").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor" }).last().click(); + await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0); + expect(state.createdBody).toMatchObject({ + companyName: "Gateway Plumbing", + contactName: "New Technician", + companyPhone: "314-555-0100", + tradeSpecialties: "HVAC, Plumbing", + notes: "Created in browser E2E", + }); + + 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.getByText("Adam Whyte")).toBeVisible(); + await expect(detailDrawer.getByText("72")).toBeVisible(); + await expect( + detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(), + ).toHaveAttribute("target", "_blank"); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); + + await detailDrawer.getByRole("button", { name: "Edit" }).click(); + await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72"); + await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly"); + await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E"); + await detailDrawer.getByRole("switch", { name: "Active" }).click(); + await detailDrawer.getByRole("button", { name: "Save" }).click(); + await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); + expect(state.updatedBody?.isActive).toBe(false); + }); + + test("supports keyboard dismissal and free-text company creation", async ({ page }) => { + const state = await mockVendorApi(page); + await page.goto("/vendors"); + + 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(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); + await expect(addButton).toBeFocused(); + + await addButton.click(); + await page.getByLabel("Technician Name").fill("Independent Tech"); + await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); + await page.getByRole("button", { name: "Add Vendor" }).last().click(); + + await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC"); + }); + + test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockVendorApi(page, { listError: true }); + await page.goto("/vendors"); + + await expect(page.getByRole("main").getByRole("alert")).toContainText( + /Vendor directory unavailable|failed|server error/i, + { timeout: 15_000 }, + ); + await page.getByRole("button", { name: "Open vendor filters" }).click(); + const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); + await expect(filterDrawer).toBeVisible(); + const box = await filterDrawer.boundingBox(); + expect(box?.width).toBeLessThanOrEqual(391); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); + await page.getByRole("button", { name: "Close filters" }).click(); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 83a6b6aa..3ec17649 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -109,6 +109,7 @@ export const API_PATHS = { update: "Vendor/Update", delete: "Vendor/Delete", dropdown: "Vendor/Dropdown", + facets: "Vendor/facets", }, calendar: { getEventsForCalendar: "Calendar/GetEventsForCalendar", diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx new file mode 100644 index 00000000..69739ea5 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from "@mui/material"; +import { VendorFormFields } from "./vendor-form-fields"; +import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; + +interface VendorCreateModalProps { + open: boolean; + onClose: () => void; + tradeOptions: string[]; +} + +const defaultValues: VendorFormValues = { + companyName: "", + contactName: "", + email: "", + phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + address: "", + city: "", + state: "", + zip: "", + tradeSpecialties: "", + isActive: true, +}; + +export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) { + const { data: facets, isLoading: isLoadingFacets, error: facetsError } = useVendorFacets(); + const createVendor = useCreateVendor(); + const [submitError, setSubmitError] = useState(null); + + const { + control, + handleSubmit, + reset, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(vendorSchema), + defaultValues, + }); + + useEffect(() => { + if (open) { + reset(defaultValues); + setSubmitError(null); + } + }, [open, reset]); + + const onCompanySelected = (company: VendorFacetCompany | null) => { + if (!company) return; + setValue("companyPhone", company.companyPhone); + setValue("email", company.email); + setValue("address", company.address); + setValue("city", company.city); + setValue("state", company.state); + setValue("zip", company.zip); + setValue("googleMapsUrl", company.googleMapsUrl); + }; + + const onSubmit = (values: VendorFormValues) => { + setSubmitError(null); + createVendor.mutate(values, { + onSuccess: () => { + onClose(); + }, + onError: (error: Error) => { + setSubmitError(error.message || "Failed to create vendor"); + }, + }); + }; + + const companies = facets?.companies ?? []; + + return ( + + + Add Vendor + + Register a technician and their vendor company. + + + + + {facetsError && ( + + Company autocomplete unavailable. You can still type a company manually. + + )} + {submitError && {submitError}} + {isLoadingFacets ? ( + + ) : ( + + + + )} + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx new file mode 100644 index 00000000..a20abdcb --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -0,0 +1,466 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import CloseIcon from "@mui/icons-material/Close"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import LaunchIcon from "@mui/icons-material/Launch"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Drawer, + FormControlLabel, + IconButton, + Link, + Stack, + Switch, + Tooltip, + Typography, +} from "@mui/material"; +import { VendorFormFields } from "./vendor-form-fields"; +import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; +import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import type { Vendor, VendorListItem } from "@/domain/vendors/types/vendor"; + +const DRAWER_WIDTH = 420; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "—"; + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); +} + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +function toFormValues(vendor: Vendor): VendorFormValues { + return { + companyName: vendor.companyName ?? "", + contactName: vendor.contactName ?? "", + email: vendor.email ?? "", + phone: vendor.phone ?? "", + companyPhone: vendor.companyPhone ?? "", + googleMapsUrl: vendor.googleMapsUrl ?? "", + notes: vendor.notes ?? "", + totalJobs: vendor.totalJobs ?? 0, + address: vendor.address ?? "", + city: vendor.city ?? "", + state: vendor.state ?? "", + zip: vendor.zip ?? "", + tradeSpecialties: vendor.tradeSpecialties ?? "", + isActive: vendor.isActive, + }; +} + +interface VendorDetailDrawerProps { + open: boolean; + vendor: VendorListItem | null; + mode: "view" | "edit"; + onClose: () => void; + onModeChange: (mode: "view" | "edit") => void; + tradeOptions: string[]; +} + +export function VendorDetailDrawer({ + open, + vendor, + mode, + onClose, + onModeChange, + tradeOptions, +}: VendorDetailDrawerProps) { + const vendorId = vendor?.id; + const { data: facets } = useVendorFacets(); + const { + data: detail, + isLoading, + error, + } = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined); + const updateVendor = useUpdateVendor(); + + const [submitError, setSubmitError] = useState(null); + const [original, setOriginal] = useState(null); + + const companies = useMemo(() => facets?.companies ?? [], [facets]); + + const { + control, + handleSubmit, + reset, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(vendorSchema), + mode: "onTouched", + defaultValues: { + companyName: "", + contactName: "", + email: "", + phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + address: "", + city: "", + state: "", + zip: "", + tradeSpecialties: "", + isActive: true, + }, + }); + + useEffect(() => { + if (open && detail) { + const values = toFormValues(detail); + reset(values); + setOriginal(values); + setSubmitError(null); + } + if (!open) { + setOriginal(null); + setSubmitError(null); + } + }, [open, detail, reset]); + + const onCompanySelected = (company: (typeof companies)[number] | null) => { + if (!company) return; + setValue("companyPhone", company.companyPhone); + setValue("email", company.email); + setValue("address", company.address); + setValue("city", company.city); + setValue("state", company.state); + setValue("zip", company.zip); + setValue("googleMapsUrl", company.googleMapsUrl); + }; + + const handleCancel = () => { + if (original) { + reset(original); + } + setSubmitError(null); + onModeChange("view"); + }; + + const onSubmit = (values: VendorFormValues) => { + if (vendorId === undefined || vendorId === null || vendorId === "") return; + setSubmitError(null); + updateVendor.mutate( + { id: vendorId, values }, + { + onSuccess: () => { + onModeChange("view"); + }, + onError: (err: Error) => { + setSubmitError(err.message || "Failed to save vendor"); + }, + }, + ); + }; + + const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties); + const mapsUrl = detail?.googleMapsUrl; + + return ( + + + `1px solid ${theme.palette.divider}`, + }} + > + + + {getInitials(vendor?.contactName ?? "")} + + + + {vendor?.companyName || "Vendor"} + + + {mode === "edit" ? "Editing vendor" : "Vendor details"} + + + + + + + + + {isLoading ? ( + + + + ) : error ? ( + + {error instanceof Error ? error.message : "Failed to load vendor"} + + ) : detail ? ( + <> + {mode === "view" ? ( + + {submitError && {submitError}} + + + + {trades[0] && ( + 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]} + /> + )} + + + + + + + + + + + + + + + + {mapsUrl && ( + + + Open in Google Maps + + )} + + + + {trades.length === 0 ? ( + + No trades specified. + + ) : ( + + {trades.map((trade, idx) => ( + + ))} + + )} + + + + + + + {detail.notes && ( + + + {detail.notes} + + + )} + + ) : ( + + + {submitError && {submitError}} + + ( + + } + label={field.value ? "Active" : "Inactive"} + sx={{ + alignSelf: "flex-start", + }} + /> + )} + /> + + + + + )} + + `1px solid ${theme.palette.divider}`, + }} + > + {mode === "view" ? ( + <> + {mapsUrl && ( + + + + )} + + + + ) : ( + <> + + + + )} + + + ) : ( + + + No vendor selected. + + + )} + + + ); +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; + return ( + + + {label} + + {display} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx new file mode 100644 index 00000000..ae4b51ba --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -0,0 +1,316 @@ +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 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"; + +interface VendorFiltersDrawerProps { + open: boolean; + onClose: () => void; + facets: VendorFacets | undefined; + appliedFilters: VendorFilters; + onApply: (filters: VendorFilters) => void; +} + +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, + facets, + appliedFilters, + onApply, +}: VendorFiltersDrawerProps) { + const [draft, setDraft] = useState(appliedFilters); + + useEffect(() => { + 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(); + }; + + return ( + + + `1px solid ${theme.palette.divider}`, + }} + > + + + Filters + + {activeCount > 0 && } + + + + + + + + }> + + + 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-filters.ts b/src/app/(protected)/vendors/_components/vendor-filters.ts new file mode 100644 index 00000000..3c236daa --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters.ts @@ -0,0 +1,13 @@ +export interface VendorFilters { + companies: string[]; + trades: string[]; + locations: string[]; + jobBuckets: string[]; +} + +export const emptyVendorFilters: VendorFilters = { + companies: [], + trades: [], + locations: [], + jobBuckets: [], +}; diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx new file mode 100644 index 00000000..f88d0505 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx @@ -0,0 +1,340 @@ +import { useState } from "react"; +import { Controller, type Control, type FieldErrors } from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import CloseIcon from "@mui/icons-material/Close"; +import { + Autocomplete, + Box, + Button, + Chip, + Divider, + FormLabel, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; + +interface VendorFormFieldsProps { + control: Control; + errors: FieldErrors; + companies: VendorFacetCompany[]; + tradeOptions: string[]; + onCompanySelected: (company: VendorFacetCompany | null) => void; + showTotalJobs?: boolean; +} + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +function joinTrades(trades: string[]): string { + return trades.join(", "); +} + +export function VendorFormFields({ + control, + errors, + companies, + tradeOptions, + onCompanySelected, + showTotalJobs = false, +}: VendorFormFieldsProps) { + const [tradeInput, setTradeInput] = useState(""); + + return ( + + + + Technician + + + ( + + )} + /> + } + /> + + + + + + + + Company + + + ( + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={field.value ?? ""} + inputValue={field.value ?? ""} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + field.onChange(value); + } + }} + onChange={(_event, value, reason) => { + if (typeof value === "string") { + field.onChange(value); + return; + } + if (value) { + field.onChange(value.name); + if (reason === "selectOption") { + onCompanySelected(value); + } + } else { + field.onChange(""); + } + }} + renderInput={(params) => ( + + )} + /> + )} + /> + } + /> + ( + + )} + /> + } + /> + + } + /> + ( + + )} + /> + } + /> + + ( + + )} + /> + + + + + + + Trade Specialties + + First trade is primary. Reorder with the arrows. + + { + const trades = splitTrades(field.value); + const remove = (trade: string) => { + field.onChange(joinTrades(trades.filter((entry) => entry !== trade))); + }; + const move = (index: number, direction: -1 | 1) => { + const next = [...trades]; + const target = index + direction; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + field.onChange(joinTrades(next)); + }; + const add = (trade: string) => { + const normalized = trade.trim(); + if (!normalized || trades.includes(normalized)) { + setTradeInput(""); + return; + } + field.onChange(joinTrades([...trades, normalized])); + setTradeInput(""); + }; + + return ( + + + {trades.length === 0 ? ( + + No trades selected. + + ) : ( + trades.map((trade, index) => ( + remove(trade)} + deleteIcon={} + sx={{ px: 0.5 }} + /> + )) + )} + + + { + if (reason === "input") setTradeInput(value); + }} + onChange={(_event, value) => { + if (typeof value === "string") add(value); + }} + renderInput={(params) => ( + { + if (event.key === "Enter") { + event.preventDefault(); + add(tradeInput); + } + }} + /> + )} + className="flex-1" + /> + + + {trades.length > 1 && ( + + {trades.map((trade, index) => ( + + + + + ))} + + )} + + ); + }} + /> + + + + + {showTotalJobs && ( + ( + + )} + /> + )} + + ( + + )} + /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-form.tsx b/src/app/(protected)/vendors/_components/vendor-form.tsx index ed7b37b9..91efc602 100644 --- a/src/app/(protected)/vendors/_components/vendor-form.tsx +++ b/src/app/(protected)/vendors/_components/vendor-form.tsx @@ -1,33 +1,37 @@ import { useEffect } from "react"; -import { Controller, useForm, useWatch } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate, useParams } from "react-router"; import { Alert, Box, Button, - Checkbox, CircularProgress, FormControlLabel, - FormGroup, - FormLabel, Paper, Stack, - TextField, + Switch, Typography, } from "@mui/material"; import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; +import { VendorFormFields } from "./vendor-form-fields"; import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; const defaultValues: VendorFormValues = { companyName: "", contactName: "", email: "", phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, address: "", city: "", state: "", @@ -42,12 +46,12 @@ export default function VendorFormPage() { const isEdit = Boolean(id); const { data: vendor, isLoading, error } = useVendorDetail(id); + const { data: facets } = useVendorFacets(); const { data: trades = [] } = useDropdownOptionsByCategory("Trade"); const createVendor = useCreateVendor(); const updateVendor = useUpdateVendor(); const { - register, handleSubmit, reset, setValue, @@ -65,6 +69,10 @@ export default function VendorFormPage() { contactName: vendor.contactName, email: vendor.email, phone: vendor.phone, + companyPhone: vendor.companyPhone, + googleMapsUrl: vendor.googleMapsUrl, + notes: vendor.notes, + totalJobs: vendor.totalJobs, address: vendor.address, city: vendor.city, state: vendor.state, @@ -75,27 +83,25 @@ export default function VendorFormPage() { } }, [vendor, isEdit, reset]); - const tradeSpecialties = useWatch({ control, name: "tradeSpecialties" }); - const selectedTrades = (tradeSpecialties || "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); - - const toggleTrade = (tradeValue: string) => { - const updated = selectedTrades.includes(tradeValue) - ? selectedTrades.filter((trade) => trade !== tradeValue) - : [...selectedTrades, tradeValue]; - setValue("tradeSpecialties", updated.join(", ")); + const onCompanySelected = (company: VendorFacetCompany | null) => { + if (!company) return; + setValue("companyPhone", company.companyPhone); + setValue("email", company.email); + setValue("address", company.address); + setValue("city", company.city); + setValue("state", company.state); + setValue("zip", company.zip); + setValue("googleMapsUrl", company.googleMapsUrl); }; const isSaving = createVendor.isPending || updateVendor.isPending; const onSubmit = (values: VendorFormValues) => { if (isEdit && id) { - updateVendor.mutate({ id, values }); + updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") }); return; } - createVendor.mutate(values); + createVendor.mutate(values, { onSuccess: () => navigate("/vendors") }); }; if (isEdit && isLoading) { @@ -120,72 +126,26 @@ export default function VendorFormPage() { - - General Details - - - - - - - - - - - - Address - - - - - - - - - - Trade Specialties - - {trades.map((trade) => ( - toggleTrade(trade.value)} - /> - } - label={trade.value} - /> - ))} - - - ( } - label="Active" + control={} + label={field.value ? "Active" : "Inactive"} /> )} /> + trade.value)} + onCompanySelected={onCompanySelected} + showTotalJobs={isEdit} + /> + {isEdit && id && } 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); +} + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +interface AppliedChip { + category: keyof VendorFilters; + value: string; + label: string; +} + +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.jobBuckets.forEach((value) => + chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + ); + return chips; +} + export default function VendorsListPage() { - const navigate = useNavigate(); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(12); + const [isActive, setIsActive] = useState(true); + const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); + const [filtersOpen, setFiltersOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + const [detailTarget, setDetailTarget] = useState(null); + const [detailMode, setDetailMode] = useState<"view" | "edit">("view"); const debouncedSearch = useDebounce(search, 300); const deleteVendor = useDeleteVendor(); + const { data: facets } = useVendorFacets(isActive); + const { data: trades = [] } = useDropdownOptionsByCategory("Trade"); + const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]); const listParams = useMemo( - () => ({ search: debouncedSearch, page: page + 1, pageSize }), - [debouncedSearch, page, pageSize], + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + isActive, + companies: appliedFilters.companies, + trades: appliedFilters.trades, + locations: appliedFilters.locations, + jobBuckets: appliedFilters.jobBuckets, + }), + [debouncedSearch, page, pageSize, isActive, appliedFilters], ); const { data, isLoading, isFetching, error } = useVendorsList(listParams); + const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); + const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; + const activeCount = activeCountData?.totalCount ?? 0; + const inactiveCount = inactiveCountData?.totalCount ?? 0; + const globalCount = activeCount + inactiveCount; + + const jobBucketLabels = useMemo( + () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), + [facets], + ); + const appliedChips = useMemo( + () => describeChips(appliedFilters, jobBucketLabels), + [appliedFilters, jobBucketLabels], + ); + + const handleApplyFilters = (next: VendorFilters) => { + setAppliedFilters(next); + setPage(0); + }; + + const handleClearFilters = () => { + setAppliedFilters(emptyVendorFilters); + setPage(0); + }; + + const handleRemoveChip = (chip: AppliedChip) => { + setAppliedFilters((current) => ({ + ...current, + [chip.category]: current[chip.category].filter((value) => value !== chip.value), + })); + setPage(0); + }; + + const openDetail = (row: VendorListItem, mode: "view" | "edit") => { + setDetailTarget(row); + setDetailMode(mode); + }; + + const closeDetail = () => { + setDetailTarget(null); + setDetailMode("view"); + }; const handleDeleteConfirm = () => { if (!deleteTarget) return; deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) }); }; + const showLoadingRow = isLoading; + const showEmptyRow = !isLoading && !error && rows.length === 0; + return ( - + - Vendors + Vendors / Technicians - - {totalCount} total + + {globalCount} technicians · {activeCount} active {isFetching && !isLoading && } @@ -90,9 +200,7 @@ export default function VendorsListPage() { - + + + { + setIsActive(value === "active"); + setPage(0); + }} + aria-label="Vendor status" + > + + + + + {appliedChips.length > 0 && ( + + + Applied: + + {appliedChips.map((chip) => ( + handleRemoveChip(chip)} + aria-label={`Remove filter ${chip.label}`} + /> + ))} + + + )} + {error && ( {error instanceof Error ? error.message : "Failed to load vendors"} )} - - + + +
    - Company - Contact - Email - Phone - Trades - Status - - Actions - + {TABLE_COLUMNS.map((column) => ( + + {column} + + ))} - {isLoading ? ( + {showLoadingRow ? ( - + - ) : rows.length === 0 ? ( + ) : showEmptyRow ? ( - - - No vendors found + + + No vendors match the current filters. ) : ( - rows.map((row) => ( - - - - - {row.contactName || "—"} - {row.email || "—"} - {row.phone || "—"} - {row.tradeSpecialties || "—"} - - - - - - { + 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(", ") || "—"; + return ( + + + {row.companyName || "—"} + + + + + {getInitials(row.contactName)} + + {row.contactName || "—"} + + + {locationText} + + {primaryTrade === "—" ? ( + "—" + ) : ( + + {primaryTrade} + {extraTrades > 0 && } + + )} + + + {row.phone ? ( + + {row.phone} + + ) : ( + "—" + )} + + + {row.email ? ( + + {row.email} + + ) : ( + "—" + )} + + {row.totalJobs ?? 0} + + navigate(`/vendors/edit/${row.id}`)} + label={row.isActive ? "Active" : "Inactive"} + color={row.isActive ? "success" : "default"} + /> + + + + openDetail(row, "view")} + > + + + + + + - - - - - setDeleteTarget(row)} - > - - - - - - )) + + openDetail(row, "edit")} + > + + + + {row.isActive && ( + + setDeleteTarget(row)} + > + + + + )} + + + + ); + }) )}
    - setPage(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => { - setPageSize(Number(e.target.value)); - setPage(0); + + + + {totalCount} {isActive ? "active" : "inactive"}{" "} + {totalCount === 1 ? "technician" : "technicians"} + {(appliedChips.length > 0 || debouncedSearch) && " · filtered"} + {` · ${activeCount} active · ${inactiveCount} inactive`} + + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + + + setCreateOpen(false)} + tradeOptions={tradeOptions} /> + + setFiltersOpen(false)} + facets={facets} + appliedFilters={appliedFilters} + onApply={handleApplyFilters} + /> + + + setDeleteTarget(null)}> - Delete Vendor + Deactivate Vendor - Are you sure you want to delete "{deleteTarget?.companyName}"? + Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit + history will be preserved. @@ -227,7 +476,7 @@ export default function VendorsListPage() { onClick={handleDeleteConfirm} disabled={deleteVendor.isPending} > - {deleteVendor.isPending ? "Deleting..." : "Delete"} + {deleteVendor.isPending ? "Deactivating..." : "Deactivate"} diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index 42ac4ac2..f375e86c 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -3,23 +3,57 @@ import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapVendor, + mapVendorFacets, mapVendorListItems, mapVendorPortalToken, mapVendorToBackend, } from "@/domain/vendors/mappers/vendor-mapper"; import type { Vendor, + VendorFacets, VendorPortalToken, VendorsListParams, VendorsListResult, } from "@/domain/vendors/types/vendor"; +function pushRepeated( + searchParams: Record, + key: string, + values: string[] | undefined, +): void { + if (!values || values.length === 0) return; + values.forEach((value, index) => { + if (!value) return; + searchParams[`${key}[${index}]`] = value; + }); +} + +export interface VendorFacetsParams { + isActive?: boolean; +} + +function buildFacetsParams(params?: VendorFacetsParams): Record { + const searchParams: Record = {}; + if (params?.isActive !== undefined) { + searchParams.isActive = String(params.isActive); + } + return searchParams; +} + function buildListParams(params: VendorsListParams): Record { - return { + const searchParams: Record = { search: params.search ?? "", page: params.page ?? 1, pageSize: params.pageSize ?? 12, }; + if (params.isActive !== undefined) { + searchParams.isActive = String(params.isActive); + } + pushRepeated(searchParams, "companies", params.companies); + pushRepeated(searchParams, "trades", params.trades); + pushRepeated(searchParams, "locations", params.locations); + pushRepeated(searchParams, "jobBuckets", params.jobBuckets); + return searchParams; } export const vendorsApi = { @@ -39,6 +73,13 @@ export const vendorsApi = { }; }, + getFacets: async (params?: VendorFacetsParams): Promise => { + const data = await apiGet(API_PATHS.vendor.facets, { + searchParams: buildFacetsParams(params), + }); + return mapVendorFacets(handleApiResponse(data)); + }, + getById: async (id: string | number): Promise => { const data = await apiGet(`${API_PATHS.rest.vendors}/${id}`); return mapVendor(handleApiResponse(data)); diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 695a0b44..569fba52 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -1,4 +1,12 @@ -import type { Vendor, VendorListItem, VendorPortalToken } from "@/domain/vendors/types/vendor"; +import type { + Vendor, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, + VendorFacets, + VendorListItem, + VendorPortalToken, +} from "@/domain/vendors/types/vendor"; function asRecord(raw: unknown): Record { return typeof raw === "object" && raw !== null ? (raw as Record) : {}; @@ -13,6 +21,18 @@ function readString(record: Record, ...keys: string[]): string return ""; } +function readNumber(record: Record, ...keys: string[]): number { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return 0; +} + function readId(record: Record): string | number { const value = record.id ?? record.Id; if (typeof value === "string" || typeof value === "number") return value; @@ -35,6 +55,12 @@ export function mapVendorListItem(raw: unknown): VendorListItem { contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), phone: readString(item, "phone", "Phone"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), + googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), + notes: readString(item, "notes", "Notes"), + totalJobs: readNumber(item, "totalJobs", "TotalJobs"), + city: readString(item, "city", "City"), + state: readString(item, "state", "State"), tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), isActive: readBool(item, "isActive", "IsActive"), }; @@ -52,6 +78,10 @@ export function mapVendor(raw: unknown): Vendor { contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), phone: readString(item, "phone", "Phone"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), + googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), + notes: readString(item, "notes", "Notes"), + totalJobs: readNumber(item, "totalJobs", "TotalJobs"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), state: readString(item, "state", "State"), @@ -68,6 +98,9 @@ export function mapVendorToBackend(vendor: unknown): Record { contactName: item.contactName ?? "", email: item.email ?? "", phone: item.phone ?? "", + companyPhone: item.companyPhone ?? "", + googleMapsUrl: item.googleMapsUrl ?? "", + notes: item.notes ?? "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", @@ -82,6 +115,62 @@ export function mapVendorToBackend(vendor: unknown): Record { return payload; } +function mapFacetCompany(raw: unknown): VendorFacetCompany { + const item = asRecord(raw); + return { + name: readString(item, "name", "Name"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), + email: readString(item, "email", "Email"), + address: readString(item, "address", "Address"), + city: readString(item, "city", "City"), + state: readString(item, "state", "State"), + zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), + googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), + }; +} + +function mapFacetLocation(raw: unknown): VendorFacetLocation { + const item = asRecord(raw); + const city = readString(item, "city", "City"); + const state = readString(item, "state", "State"); + const label = readString(item, "label", "Label"); + return { + city, + state, + label: label || [city, state].filter(Boolean).join(", "), + }; +} + +function mapFacetJobBucket(raw: unknown): VendorFacetJobBucket { + const item = asRecord(raw); + return { + id: readString(item, "id", "Id"), + label: readString(item, "label", "Label"), + }; +} + +export function mapVendorFacets(raw: unknown): VendorFacets { + const item = asRecord(raw); + const companiesRaw = item.companies ?? item.Companies; + const tradesRaw = item.trades ?? item.Trades; + const locationsRaw = item.locations ?? item.Locations; + const jobBucketsRaw = item.jobBuckets ?? item.JobBuckets; + + return { + companies: Array.isArray(companiesRaw) ? companiesRaw.map(mapFacetCompany) : [], + trades: Array.isArray(tradesRaw) + ? tradesRaw + .map((entry) => { + if (typeof entry === "string") return entry; + return readString(asRecord(entry), "name", "Name", "value", "Value", "label", "Label"); + }) + .filter(Boolean) + : [], + locations: Array.isArray(locationsRaw) ? locationsRaw.map(mapFacetLocation) : [], + jobBuckets: Array.isArray(jobBucketsRaw) ? jobBucketsRaw.map(mapFacetJobBucket) : [], + }; +} + export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null { const item = asRecord(raw); const portalUrl = readString(item, "portalUrl", "PortalUrl"); diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index cb33e82d..3425730f 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -1,10 +1,22 @@ import { z } from "zod"; +const httpsUrl = z + .string() + .trim() + .refine( + (value) => value === "" || /^https:\/\/.+/i.test(value), + "Google Maps URL must use HTTPS", + ); + export const vendorSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - contactName: z.string().optional(), - email: z.string().min(1, "Email is required").email("Invalid email"), + companyName: z.string().min(1, "Company is required"), + contactName: z.string().min(1, "Technician name is required"), + email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(), phone: z.string().optional(), + companyPhone: z.string().optional(), + googleMapsUrl: httpsUrl.optional(), + notes: z.string().optional(), + totalJobs: z.number().optional(), address: z.string().optional(), city: z.string().optional(), state: z.string().optional(), diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index 71a41551..ffc67faa 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -4,6 +4,12 @@ export interface VendorListItem { contactName: string; email: string; phone: string; + companyPhone: string; + googleMapsUrl: string; + notes: string; + totalJobs: number; + city: string; + state: string; tradeSpecialties: string; isActive: boolean; } @@ -14,6 +20,10 @@ export interface Vendor { contactName: string; email: string; phone: string; + companyPhone: string; + googleMapsUrl: string; + notes: string; + totalJobs: number; address: string; city: string; state: string; @@ -24,8 +34,13 @@ export interface Vendor { export interface VendorsListParams { search?: string; + isActive?: boolean; page?: number; pageSize?: number; + companies?: string[]; + trades?: string[]; + locations?: string[]; + jobBuckets?: string[]; } export interface VendorsListResult { @@ -35,6 +50,35 @@ export interface VendorsListResult { pageSize: number; } +export interface VendorFacetCompany { + name: string; + companyPhone: string; + email: string; + address: string; + city: string; + state: string; + zip: string; + googleMapsUrl: string; +} + +export interface VendorFacetLocation { + city: string; + state: string; + label: string; +} + +export interface VendorFacetJobBucket { + id: string; + label: string; +} + +export interface VendorFacets { + companies: VendorFacetCompany[]; + trades: string[]; + locations: VendorFacetLocation[]; + jobBuckets: VendorFacetJobBucket[]; +} + export interface VendorPortalToken { portalUrl: string; issuedAt: string; diff --git a/src/domain/vendors/use-cases/use-create-vendor.ts b/src/domain/vendors/use-cases/use-create-vendor.ts index 7e3283a4..fa1c730f 100644 --- a/src/domain/vendors/use-cases/use-create-vendor.ts +++ b/src/domain/vendors/use-cases/use-create-vendor.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; @@ -7,7 +6,6 @@ import type { Vendor } from "@/domain/vendors/types/vendor"; import { queryKeys } from "@/infra/query-key/query-key"; export function useCreateVendor(): UseMutationResult { - const navigate = useNavigate(); const queryClient = useQueryClient(); return useMutation({ @@ -15,7 +13,6 @@ export function useCreateVendor(): UseMutationResult { void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); toast.success("Vendor created"); - navigate("/vendors"); }, onError: (error: Error) => { toast.error(error.message || "Failed to create vendor"); diff --git a/src/domain/vendors/use-cases/use-delete-vendor.ts b/src/domain/vendors/use-cases/use-delete-vendor.ts index 20c7e5a5..0457cc7e 100644 --- a/src/domain/vendors/use-cases/use-delete-vendor.ts +++ b/src/domain/vendors/use-cases/use-delete-vendor.ts @@ -10,10 +10,10 @@ export function useDeleteVendor(): UseMutationResult vendorsApi.delete(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); - toast.success("Vendor deleted"); + toast.success("Vendor deactivated"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to delete vendor"); + toast.error(error.message || "Failed to deactivate vendor"); }, }); } diff --git a/src/domain/vendors/use-cases/use-update-vendor.ts b/src/domain/vendors/use-cases/use-update-vendor.ts index 34c61c9a..cf4f4c84 100644 --- a/src/domain/vendors/use-cases/use-update-vendor.ts +++ b/src/domain/vendors/use-cases/use-update-vendor.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { vendorsApi } from "@/domain/vendors/api/vendors-api"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; @@ -12,7 +11,6 @@ interface UpdateVendorInput { } export function useUpdateVendor(): UseMutationResult { - const navigate = useNavigate(); const queryClient = useQueryClient(); return useMutation({ @@ -21,7 +19,6 @@ export function useUpdateVendor(): UseMutationResult { toast.error(error.message || "Failed to update vendor"); diff --git a/src/domain/vendors/use-cases/use-vendor-facets.ts b/src/domain/vendors/use-cases/use-vendor-facets.ts new file mode 100644 index 00000000..2aef7d19 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-facets.ts @@ -0,0 +1,13 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { vendorsApi, type VendorFacetsParams } from "@/domain/vendors/api/vendors-api"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useVendorFacets(isActive?: boolean): UseQueryResult { + const params: VendorFacetsParams | undefined = isActive !== undefined ? { isActive } : undefined; + return useQuery({ + queryKey: queryKeys.vendors.facets(isActive), + queryFn: () => vendorsApi.getFacets(params), + staleTime: 60_000, + }); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 21544914..bc65dfe2 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -56,6 +56,7 @@ export const queryKeys = { list: (params?: Record) => [...queryKeys.vendors.all, "list", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.vendors.all, "detail", id] as const, + facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const, portalToken: (vendorId: string | number) => [...queryKeys.vendors.all, "portalToken", vendorId] as const, }, diff --git a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx new file mode 100644 index 00000000..eb197115 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx @@ -0,0 +1,151 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, type FieldErrors } from "react-hook-form"; +import { describe, expect, it } from "vitest"; +import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields"; +import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import { renderWithProviders } from "@/test/test-utils"; + +const companies: VendorFacetCompany[] = [ + { + name: "Gateway Plumbing", + companyPhone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + address: "1 Main St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "https://maps.google.com/gateway", + }, +]; + +const emptyForm: VendorFormValues = { + companyName: "", + contactName: "Tech Person", + email: "", + phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + address: "", + city: "", + state: "", + zip: "", + tradeSpecialties: "", + isActive: true, +}; + +function Harness({ + defaultValues, + resetTarget, +}: { + defaultValues: VendorFormValues; + resetTarget: VendorFormValues; +}) { + const { control, reset, setValue } = useForm({ + resolver: zodResolver(vendorSchema), + defaultValues, + }); + + const onCompanySelected = (company: VendorFacetCompany | null) => { + if (!company) return; + setValue("companyPhone", company.companyPhone); + setValue("email", company.email); + setValue("address", company.address); + setValue("city", company.city); + setValue("state", company.state); + setValue("zip", company.zip); + setValue("googleMapsUrl", company.googleMapsUrl); + }; + + return ( + <> + } + companies={companies} + tradeOptions={[]} + onCompanySelected={onCompanySelected} + /> + + + ); +} + +const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement; + +describe("VendorFormFields company autocomplete state", () => { + it("clears a previously typed company when the form is reset (create-form reopen)", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Leftover Co"); + expect(companyField()).toHaveValue("Leftover Co"); + + await userEvent.click(screen.getByRole("button", { name: "reset-form" })); + + expect(companyField()).toHaveValue(""); + }); + + it("shows the switched company value instead of previously typed text when the form is reset", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + await userEvent.type(companyField(), "Acme"); + expect(companyField()).toHaveValue("Acme"); + + await userEvent.click(screen.getByRole("button", { name: "reset-form" })); + + expect(companyField()).toHaveValue("Beta LLC"); + }); + + it("reflects the company provided by an external reset even when nothing was typed", async () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(companyField()).toHaveValue("Acme Corp"); + + await userEvent.click(screen.getByRole("button", { name: "reset-form" })); + + expect(companyField()).toHaveValue("Beta LLC"); + }); + + it("updates the displayed company as the user types (freeSolo)", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Acme"); + + expect(companyField()).toHaveValue("Acme"); + }); + + it("selects a company option, autofills company details, and shows the company name", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Gate"); + const option = await screen.findByRole("option", { name: "Gateway Plumbing" }); + await userEvent.click(option); + + expect(companyField()).toHaveValue("Gateway Plumbing"); + expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198"); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx new file mode 100644 index 00000000..1db80de8 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -0,0 +1,86 @@ +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 { renderWithProviders } from "@/test/test-utils"; + +const useVendorsList = vi.fn(); +const mutate = vi.fn(); + +vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ + useVendorsList: (...args: unknown[]) => useVendorsList(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({ + useDeleteVendor: () => ({ mutate, isPending: false }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const activeVendor = { + id: 1, + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + email: "dispatch@gatewayplumbing.com", + phone: "(314) 555-0198", + city: "St. Louis", + state: "MO", + tradeSpecialties: "Backflow Preventers", + isActive: true, +}; + +const inactiveVendor = { + ...activeVendor, + id: 2, + companyName: "Drain Cleaning Bros", + isActive: false, +}; + +function result(items: (typeof activeVendor)[], totalCount: number) { + return { + data: { items, totalCount, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }; +} + +describe("VendorsListPage", () => { + it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); + return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); + expect(screen.getByText("St. Louis, MO")).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.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); + }); + + it("reports the filtered totalCount in the footer when page rows are fewer than total", () => { + const pageVendors = Array.from({ length: 3 }, (_, index) => ({ + ...activeVendor, + id: 100 + index, + companyName: `Vendor ${index + 1}`, + })); + + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], params.isActive ? 25 : 0); + return result(pageVendors, 25); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts new file mode 100644 index 00000000..554ffcc6 --- /dev/null +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiPut = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiPut: (...args: unknown[]) => apiPut(...args), + apiDelete: vi.fn(), +})); + +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; + +describe("vendorsApi.getList", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiPut.mockReset(); + apiGet.mockResolvedValue({ + Data: [ + { + Id: 7, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + City: "St. Louis", + State: "MO", + IsActive: false, + }, + ], + TotalCount: 1, + }); + }); + + it("forwards the status filter and maps list location fields", async () => { + const result = await vendorsApi.getList({ search: "gateway", isActive: false, page: 1 }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, { + searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, + }); + expect(result.items[0]).toMatchObject({ city: "St. Louis", state: "MO", isActive: false }); + }); + + it("forwards all selected facets as repeated indexed query parameters", async () => { + await vendorsApi.getList({ + companies: ["Gateway Plumbing", "Metro HVAC"], + trades: ["Plumbing"], + locations: ["St. Louis, MO"], + jobBuckets: ["50-99"], + }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, { + searchParams: { + search: "", + page: 1, + pageSize: 12, + "companies[0]": "Gateway Plumbing", + "companies[1]": "Metro HVAC", + "trades[0]": "Plumbing", + "locations[0]": "St. Louis, MO", + "jobBuckets[0]": "50-99", + }, + }); + }); + + it("maps company profiles and labels returned by the facets endpoint", async () => { + apiGet.mockResolvedValueOnce({ + data: { + companies: [ + { + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "https://maps.google.com/gateway", + }, + ], + trades: ["Plumbing"], + locations: [{ city: "St. Louis", state: "MO" }], + jobBuckets: [{ id: "50-99", label: "50–99" }], + }, + }); + + const facets = await vendorsApi.getFacets(); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} }); + expect(facets.companies[0]).toMatchObject({ + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + googleMapsUrl: "https://maps.google.com/gateway", + }); + expect(facets.locations[0]?.label).toBe("St. Louis, MO"); + expect(facets.jobBuckets[0]).toEqual({ id: "50-99", label: "50–99" }); + }); + + it("serializes the requested facet scope as an isActive query parameter", async () => { + apiGet.mockResolvedValue({ + data: { companies: [], trades: [], locations: [], jobBuckets: [] }, + }); + + await vendorsApi.getFacets({ isActive: true }); + await vendorsApi.getFacets({ isActive: false }); + + expect(apiGet).toHaveBeenNthCalledWith(1, API_PATHS.vendor.facets, { + searchParams: { isActive: "true" }, + }); + expect(apiGet).toHaveBeenNthCalledWith(2, API_PATHS.vendor.facets, { + searchParams: { isActive: "false" }, + }); + }); + + it("sends all editable prototype fields when creating and updating", async () => { + const values = { + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + phone: "314-555-0198", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + tradeSpecialties: "Plumbing, HVAC", + googleMapsUrl: "https://maps.google.com/gateway", + notes: "Preferred after-hours vendor", + totalJobs: 72, + isActive: true, + }; + apiPost.mockResolvedValueOnce({ data: values }); + apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } }); + + await vendorsApi.create(values); + await vendorsApi.update(7, { ...values, isActive: false }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes }), + ); + expect(apiPut).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7`, + expect.objectContaining({ companyName: "Gateway Plumbing", isActive: false }), + ); + expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); + expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); + }); +}); diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts new file mode 100644 index 00000000..2c53d784 --- /dev/null +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema"; + +const validVendor = { + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + email: "dispatch@gateway.test", + googleMapsUrl: "https://maps.google.com/gateway", +}; + +describe("vendorSchema", () => { + it("requires both the company and technician identity", () => { + const result = vendorSchema.safeParse({ ...validVendor, companyName: "", contactName: "" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.companyName).toContain("Company is required"); + expect(result.error.flatten().fieldErrors.contactName).toContain( + "Technician name is required", + ); + } + }); + + it("accepts an empty optional email and HTTPS map URL", () => { + expect(vendorSchema.safeParse({ ...validVendor, email: "" }).success).toBe(true); + }); + + it("rejects malformed email and non-HTTPS map links", () => { + expect( + vendorSchema.safeParse({ + ...validVendor, + email: "not-an-email", + googleMapsUrl: "http://maps.google.com/gateway", + }).success, + ).toBe(false); + }); +}); diff --git a/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx b/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx new file mode 100644 index 00000000..c732e116 --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-vendor-facets.test.tsx @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const getFacets = vi.fn(); + +vi.mock("@/domain/vendors/api/vendors-api", () => ({ + vendorsApi: { getFacets: (...args: unknown[]) => getFacets(...args) }, +})); + +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import { queryKeys } from "@/infra/query-key/query-key"; + +const emptyFacets = { companies: [], trades: [], locations: [], jobBuckets: [] }; +const activeFacets = { ...emptyFacets, companies: [{ name: "Active Co" }] }; +const inactiveFacets = { ...emptyFacets, companies: [{ name: "Inactive Co" }] }; + +function createClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useVendorFacets", () => { + beforeEach(() => { + getFacets.mockReset(); + }); + + it("switches facet requests true/false as the tab scope changes", async () => { + getFacets.mockResolvedValue(activeFacets); + const { rerender } = renderHook(({ isActive }) => useVendorFacets(isActive), { + initialProps: { isActive: true as boolean | undefined }, + wrapper: makeWrapper(createClient()), + }); + + await waitFor(() => expect(getFacets).toHaveBeenCalledWith({ isActive: true })); + + getFacets.mockResolvedValue(inactiveFacets); + rerender({ isActive: false }); + await waitFor(() => expect(getFacets).toHaveBeenCalledWith({ isActive: false })); + + getFacets.mockResolvedValue(activeFacets); + rerender({ isActive: undefined }); + await waitFor(() => expect(getFacets).toHaveBeenCalledWith(undefined)); + }); + + it("scopes the cache so active, inactive, and unscoped keys cannot collide", () => { + const active = queryKeys.vendors.facets(true); + const inactive = queryKeys.vendors.facets(false); + const unscoped = queryKeys.vendors.facets(); + + expect(active).not.toEqual(inactive); + expect(active).not.toEqual(unscoped); + expect(inactive).not.toEqual(unscoped); + expect(active[active.length - 1]).toBe(true); + expect(inactive[inactive.length - 1]).toBe(false); + expect(unscoped[unscoped.length - 1]).toBeNull(); + }); + + it("reuses the cached facet entry when returning to a previous scope", async () => { + getFacets.mockResolvedValue(activeFacets); + const client = createClient(); + const { result, rerender } = renderHook(({ isActive }) => useVendorFacets(isActive), { + initialProps: { isActive: true as boolean | undefined }, + wrapper: makeWrapper(client), + }); + + await waitFor(() => expect(getFacets).toHaveBeenCalledTimes(1)); + + getFacets.mockResolvedValue(inactiveFacets); + rerender({ isActive: false }); + await waitFor(() => expect(getFacets).toHaveBeenCalledTimes(2)); + + getFacets.mockClear(); + rerender({ isActive: true }); + await waitFor(() => expect(result.current.data).toEqual(activeFacets)); + expect(getFacets).not.toHaveBeenCalled(); + }); +}); From bbbb51aed53eaaec42dbd1a290c93f13d0adfb6a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 17:36:21 -0300 Subject: [PATCH 02/10] feat(vendors): complete core vendor workflows --- e2e/vendors/vendors.spec.ts | 89 +++++++++- src/api/api-paths.ts | 1 + .../_components/vendor-create-modal.tsx | 27 ++- .../_components/vendor-detail-drawer.tsx | 42 +++-- .../_components/vendor-form-fields.tsx | 160 +++++++++++++----- .../vendors/_components/vendor-form.tsx | 29 +++- src/app/(protected)/vendors/index.tsx | 155 +++++++++++++++-- src/domain/vendors/api/vendors-api.ts | 9 + src/domain/vendors/mappers/vendor-mapper.ts | 113 +++++++++++-- src/domain/vendors/schemas/vendor-schema.ts | 19 ++- src/domain/vendors/types/vendor.ts | 24 +++ .../use-vendor-deactivation-impact.ts | 22 +++ src/infra/query-key/query-key.ts | 2 + .../vendors/vendor-form-fields.test.tsx | 56 +++++- .../(protected)/vendors/vendors-list.test.tsx | 77 +++++++++ .../domain/vendors/api/vendors-api.test.ts | 90 +++++++++- .../vendors/schemas/vendor-schema.test.ts | 25 +++ 17 files changed, 824 insertions(+), 116 deletions(-) create mode 100644 src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e7c42c26..a923336c 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -13,6 +13,7 @@ const TEST_USER = { const activeVendors = [ { Id: 1, + CompanyId: 101, CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", Email: "dispatch@gateway.test", @@ -27,9 +28,11 @@ const activeVendors = [ Notes: "Preferred after-hours vendor", TotalJobs: 72, IsActive: true, + PreferredContact: "Email", }, { Id: 2, + CompanyId: 102, CompanyName: "Metro HVAC", ContactName: "Maya Chen", Email: "maya@metro.test", @@ -75,7 +78,10 @@ async function captureVisual(page: Page, path: string) { await page.screenshot({ path, fullPage: true }); } -async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) { +async function mockVendorApi( + page: Page, + options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {}, +) { const state: MockState = { listUrls: [] }; await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); @@ -92,6 +98,7 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) fulfillJson(route, { data: { companies: activeVendors.map((vendor) => ({ + companyId: vendor.CompanyId, name: vendor.CompanyName, companyPhone: vendor.CompanyPhone, email: vendor.Email, @@ -143,6 +150,27 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) PageSize: Number(url.searchParams.get("pageSize") ?? 12), }); }); + await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => { + const vendorId = Number(route.request().url().split("/").at(-2)); + await fulfillJson(route, { + data: { + vendorId, + canDeactivate: !options.deactivationBlocked, + openWorkOrders: options.deactivationBlocked + ? [ + { + workOrderId: 501, + workOrderNumber: "WO-501", + workOrderTitle: "Emergency boiler repair", + status: "Dispatched", + scheduledDate: "2026-07-24T14:00:00Z", + dispatchId: 801, + }, + ] + : [], + }, + }); + }); await page.route(/\/api\/vendors\/\d+$/, async (route) => { const id = route.request().url().split("/").pop() ?? ""; if (route.request().method() === "PUT") { @@ -153,6 +181,14 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) return; } if (route.request().method() === "DELETE") { + if (options.deleteConflict) { + await fulfillJson( + route, + { message: "Vendor has open work orders and cannot be deactivated." }, + 409, + ); + return; + } state.deletedId = id; await fulfillJson(route, { status: "200" }); return; @@ -248,15 +284,13 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "+ Add Vendor" }).click(); await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); - await page.getByRole("button", { name: "Add Vendor" }).last().click(); - await expect(page.getByText("Technician name is required")).toBeVisible(); - await expect(page.getByText("Company is required")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); await page.getByLabel("Technician Name").fill("New Technician"); 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.getByLabel("Email")).toHaveValue("dispatch@gateway.test"); + await expect(page.getByLabel("Company Phone")).toHaveValue("(314) 555-0100"); + await expect(page.getByRole("textbox", { name: "Email" })).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 page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing"); @@ -265,12 +299,19 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "Add trade" }).click(); await page.getByRole("button", { name: "Move HVAC up" }).click(); await page.getByLabel("Notes").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Text" }).click(); + await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute( + "aria-pressed", + "true", + ); await page.getByRole("button", { name: "Add Vendor" }).last().click(); await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0); expect(state.createdBody).toMatchObject({ companyName: "Gateway Plumbing", + companyId: 101, contactName: "New Technician", - companyPhone: "314-555-0100", + companyPhone: "(314) 555-0100", + preferredContact: "Text", tradeSpecialties: "HVAC, Plumbing", notes: "Created in browser E2E", }); @@ -308,11 +349,45 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); + await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); await page.getByLabel("Technician Name").fill("Independent Tech"); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); await page.getByRole("button", { name: "Add Vendor" }).last().click(); await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC"); + expect(state.createdBody?.companyId).toBeNull(); + }); + + test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({ + page, + }) => { + const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); + await page.goto("/vendors"); + + 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(); + expect(blockedState.deletedId).toBeUndefined(); + + await blockedDialog.getByRole("button", { name: "Cancel" }).click(); + await page.unrouteAll({ behavior: "wait" }); + + const racedState = await mockVendorApi(page, { deleteConflict: true }); + await page.reload(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page + .getByRole("dialog", { name: "Deactivate Vendor" }) + .getByRole("button", { + name: /^Deactivate$/, + }) + .click(); + + await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( + /open work orders|conflict/i, + ); + expect(racedState.deletedId).toBeUndefined(); + await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => { diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 3ec17649..9b2f25eb 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -110,6 +110,7 @@ export const API_PATHS = { delete: "Vendor/Delete", dropdown: "Vendor/Dropdown", facets: "Vendor/facets", + deactivationImpact: "deactivation-impact", }, calendar: { getEventsForCalendar: "Calendar/GetEventsForCalendar", diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 69739ea5..9f74d2af 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, @@ -40,6 +40,8 @@ const defaultValues: VendorFormValues = { zip: "", tradeSpecialties: "", isActive: true, + companyId: null, + preferredContact: "Phone", }; export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) { @@ -52,11 +54,15 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM handleSubmit, reset, setValue, + trigger, formState: { errors }, } = useForm({ resolver: zodResolver(vendorSchema), defaultValues, + mode: "onChange", }); + const currentValues = useWatch({ control }); + const isFormValid = vendorSchema.safeParse(currentValues).success; useEffect(() => { if (open) { @@ -67,13 +73,16 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM const onCompanySelected = (company: VendorFacetCompany | null) => { if (!company) return; - setValue("companyPhone", company.companyPhone); - setValue("email", company.email); - setValue("address", company.address); - setValue("city", company.city); - setValue("state", company.state); - setValue("zip", company.zip); - setValue("googleMapsUrl", company.googleMapsUrl); + const options = { shouldDirty: true, shouldValidate: true } as const; + setValue("companyId", company.companyId, options); + setValue("companyPhone", company.companyPhone, options); + setValue("email", company.email, options); + setValue("address", company.address, options); + setValue("city", company.city, options); + setValue("state", company.state, options); + setValue("zip", company.zip, options); + setValue("googleMapsUrl", company.googleMapsUrl, options); + void trigger(); }; const onSubmit = (values: VendorFormValues) => { @@ -136,7 +145,7 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM color="primary" variant="contained" onClick={handleSubmit(onSubmit)} - disabled={createVendor.isPending} + disabled={createVendor.isPending || !isFormValid} > {createVendor.isPending ? "Saving..." : "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 a20abdcb..18d72a6d 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, useMemo, useState, type ReactNode } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, useWatch, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; @@ -24,10 +24,16 @@ import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/ve import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import type { Vendor, VendorListItem } from "@/domain/vendors/types/vendor"; +import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor"; const DRAWER_WIDTH = 420; +const PREFERRED_CONTACT_LABELS: Record = { + Phone: "Phone", + Email: "Email", + Text: "Text Message", +}; + function getInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "—"; @@ -59,6 +65,8 @@ function toFormValues(vendor: Vendor): VendorFormValues { zip: vendor.zip ?? "", tradeSpecialties: vendor.tradeSpecialties ?? "", isActive: vendor.isActive, + companyId: vendor.companyId ?? null, + preferredContact: vendor.preferredContact ?? "Phone", }; } @@ -98,10 +106,11 @@ export function VendorDetailDrawer({ handleSubmit, reset, setValue, + trigger, formState: { errors }, } = useForm({ resolver: zodResolver(vendorSchema), - mode: "onTouched", + mode: "onChange", defaultValues: { companyName: "", contactName: "", @@ -117,8 +126,12 @@ export function VendorDetailDrawer({ zip: "", tradeSpecialties: "", isActive: true, + companyId: null, + preferredContact: "Phone", }, }); + const currentValues = useWatch({ control }); + const isFormValid = vendorSchema.safeParse(currentValues).success; useEffect(() => { if (open && detail) { @@ -135,13 +148,16 @@ export function VendorDetailDrawer({ const onCompanySelected = (company: (typeof companies)[number] | null) => { if (!company) return; - setValue("companyPhone", company.companyPhone); - setValue("email", company.email); - setValue("address", company.address); - setValue("city", company.city); - setValue("state", company.state); - setValue("zip", company.zip); - setValue("googleMapsUrl", company.googleMapsUrl); + const options = { shouldDirty: true, shouldValidate: true } as const; + setValue("companyId", company.companyId, options); + setValue("companyPhone", company.companyPhone, options); + setValue("email", company.email, options); + setValue("address", company.address, options); + setValue("city", company.city, options); + setValue("state", company.state, options); + setValue("zip", company.zip, options); + setValue("googleMapsUrl", company.googleMapsUrl, options); + void trigger(); }; const handleCancel = () => { @@ -268,6 +284,10 @@ export function VendorDetailDrawer({ + @@ -417,7 +437,7 @@ export function VendorDetailDrawer({ variant="contained" type="submit" form="vendor-edit-form" - disabled={updateVendor.isPending} + disabled={updateVendor.isPending || !isFormValid} fullWidth > {updateVendor.isPending ? "Saving..." : "Save"} diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx index f88d0505..06fe52b3 100644 --- a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx @@ -13,9 +13,15 @@ import { FormLabel, Stack, TextField, + ToggleButton, + ToggleButtonGroup, Typography, } from "@mui/material"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import { + VENDOR_PREFERRED_CONTACT_VALUES, + type VendorFacetCompany, + type VendorPreferredContact, +} from "@/domain/vendors/types/vendor"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; interface VendorFormFieldsProps { @@ -38,6 +44,20 @@ function joinTrades(trades: string[]): string { return trades.join(", "); } +function formatPhoneMask(input: string): string { + const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10); + if (digits.length === 0) return ""; + if (digits.length < 4) return `(${digits}`; + if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + +const PREFERRED_CONTACT_LABELS: Record = { + Phone: "Phone", + Email: "Email", + Text: "Text", +}; + export function VendorFormFields({ control, errors, @@ -72,8 +92,46 @@ export function VendorFormFields({ } + render={({ field }) => ( + field.onChange(formatPhoneMask(event.target.value))} + error={Boolean(errors.phone)} + helperText={errors.phone?.message} + fullWidth + /> + )} /> + + + Preferred Contact + + ( + { + if (value) field.onChange(value); + }} + aria-label="Preferred contact" + > + {VENDOR_PREFERRED_CONTACT_VALUES.map((option) => ( + + {PREFERRED_CONTACT_LABELS[option]} + + ))} + + )} + /> +
    @@ -88,44 +146,54 @@ export function VendorFormFields({ control={control} name="companyName" render={({ field }) => ( - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={field.value ?? ""} - inputValue={field.value ?? ""} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - field.onChange(value); - } - }} - onChange={(_event, value, reason) => { - if (typeof value === "string") { - field.onChange(value); - return; - } - if (value) { - field.onChange(value.name); - if (reason === "selectOption") { - onCompanySelected(value); + ( + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name } - } else { - field.onChange(""); - } - }} - renderInput={(params) => ( - { + if (reason === "input") { + field.onChange(value); + companyIdField.onChange(null); + } + }} + onChange={(_event, value, reason) => { + if (typeof value === "string") { + field.onChange(value); + companyIdField.onChange(null); + return; + } + if (value) { + field.onChange(value.name); + companyIdField.onChange(value.companyId); + if (reason === "selectOption") { + onCompanySelected(value); + } + } else { + field.onChange(""); + companyIdField.onChange(null); + } + }} + renderInput={(params) => ( + + )} /> )} /> @@ -134,7 +202,19 @@ export function VendorFormFields({ } + render={({ field }) => ( + field.onChange(formatPhoneMask(event.target.value))} + error={Boolean(errors.companyPhone)} + helperText={errors.companyPhone?.message} + fullWidth + /> + )} /> ({ resolver: zodResolver(vendorSchema), defaultValues, + mode: "onChange", }); + const currentValues = useWatch({ control }); + const isFormValid = vendorSchema.safeParse(currentValues).success; useEffect(() => { if (vendor && isEdit) { @@ -79,19 +85,24 @@ export default function VendorFormPage() { zip: vendor.zip, tradeSpecialties: vendor.tradeSpecialties, isActive: vendor.isActive, + companyId: vendor.companyId ?? null, + preferredContact: vendor.preferredContact ?? "Phone", }); } }, [vendor, isEdit, reset]); const onCompanySelected = (company: VendorFacetCompany | null) => { if (!company) return; - setValue("companyPhone", company.companyPhone); - setValue("email", company.email); - setValue("address", company.address); - setValue("city", company.city); - setValue("state", company.state); - setValue("zip", company.zip); - setValue("googleMapsUrl", company.googleMapsUrl); + const options = { shouldDirty: true, shouldValidate: true } as const; + setValue("companyId", company.companyId, options); + setValue("companyPhone", company.companyPhone, options); + setValue("email", company.email, options); + setValue("address", company.address, options); + setValue("city", company.city, options); + setValue("state", company.state, options); + setValue("zip", company.zip, options); + setValue("googleMapsUrl", company.googleMapsUrl, options); + void trigger(); }; const isSaving = createVendor.isPending || updateVendor.isPending; @@ -158,7 +169,7 @@ export default function VendorFormPage() { -
    diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 62430d7d..fa905238 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import FilterListIcon from "@mui/icons-material/FilterList"; @@ -17,6 +17,8 @@ import { DialogTitle, IconButton, Link, + List, + ListItem, Paper, Stack, Tab, @@ -38,6 +40,7 @@ import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer"; import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor"; +import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; @@ -100,11 +103,17 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(null); const [detailTarget, setDetailTarget] = useState(null); const [detailMode, setDetailMode] = useState<"view" | "edit">("view"); const debouncedSearch = useDebounce(search, 300); const deleteVendor = useDeleteVendor(); + const { + data: deactivationImpact, + isLoading: isLoadingImpact, + error: impactError, + } = useVendorDeactivationImpact(deleteTarget?.id); const { data: facets } = useVendorFacets(isActive); const { data: trades = [] } = useDropdownOptionsByCategory("Trade"); const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]); @@ -169,9 +178,39 @@ export default function VendorsListPage() { setDetailMode("view"); }; + const openDeactivation = (row: VendorListItem) => { + setDeleteTarget(row); + setDeleteError(null); + }; + + const closeDeactivation = () => { + setDeleteTarget(null); + setDeleteError(null); + }; + const handleDeleteConfirm = () => { if (!deleteTarget) return; - deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) }); + setDeleteError(null); + deleteVendor.mutate(deleteTarget.id, { + onSuccess: () => { + setDeleteTarget(null); + }, + onError: (error: Error) => { + setDeleteError(error.message || "Failed to deactivate vendor"); + }, + }); + }; + + const stopAnd = (handler: () => void) => (event: MouseEvent) => { + event.stopPropagation(); + handler(); + }; + + const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + openDetail(row, "view"); }; const showLoadingRow = isLoading; @@ -304,7 +343,16 @@ export default function VendorsListPage() { const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; return ( - + openDetail(row, "view")} + onKeyDown={handleRowKeyDown(row)} + > {row.companyName || "—"} @@ -336,7 +384,11 @@ export default function VendorsListPage() { {row.phone ? ( - + event.stopPropagation()} + > {row.phone} ) : ( @@ -345,7 +397,11 @@ export default function VendorsListPage() { {row.email ? ( - + event.stopPropagation()} + > {row.email} ) : ( @@ -365,7 +421,7 @@ export default function VendorsListPage() { openDetail(row, "view")} + onClick={stopAnd(() => openDetail(row, "view"))} > @@ -381,7 +437,7 @@ export default function VendorsListPage() { openDetail(row, "edit")} + onClick={stopAnd(() => openDetail(row, "edit"))} > @@ -391,7 +447,7 @@ export default function VendorsListPage() { setDeleteTarget(row)} + onClick={stopAnd(() => openDeactivation(row))} > @@ -458,23 +514,92 @@ export default function VendorsListPage() { tradeOptions={tradeOptions} /> - setDeleteTarget(null)}> + Deactivate Vendor - - Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit - history will be preserved. - + + + Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit + history will be preserved. + + + {isLoadingImpact && ( + + + + Checking linked work orders... + + + )} + + {impactError && !isLoadingImpact && ( + + Could not verify linked work orders. Close and try again. + + )} + + {deactivationImpact && !isLoadingImpact && !deactivationImpact.canDeactivate && ( + + This vendor cannot be deactivated because it still has open work orders. + + )} + + {deactivationImpact && deactivationImpact.openWorkOrders.length > 0 && ( + + + Open work orders ({deactivationImpact.openWorkOrders.length}) + + + {deactivationImpact.openWorkOrders.map((wo) => ( + + {wo.scheduledDate} + + ) : undefined + } + > + + + {wo.workOrderNumber + ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` + : wo.workOrderTitle || `Work order ${wo.workOrderId}`} + + {wo.status && ( + + {wo.status} + + )} + + + ))} + + + )} + + {deleteError && {deleteError}} + - diff --git a/src/domain/vendors/api/vendors-api.ts b/src/domain/vendors/api/vendors-api.ts index f375e86c..4f001f04 100644 --- a/src/domain/vendors/api/vendors-api.ts +++ b/src/domain/vendors/api/vendors-api.ts @@ -3,6 +3,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { mapVendor, + mapVendorDeactivationImpact, mapVendorFacets, mapVendorListItems, mapVendorPortalToken, @@ -10,6 +11,7 @@ import { } from "@/domain/vendors/mappers/vendor-mapper"; import type { Vendor, + VendorDeactivationImpact, VendorFacets, VendorPortalToken, VendorsListParams, @@ -102,6 +104,13 @@ export const vendorsApi = { await apiDelete(`${API_PATHS.rest.vendors}/${id}`); }, + getDeactivationImpact: async (id: string | number): Promise => { + const data = await apiGet( + `${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`, + ); + return mapVendorDeactivationImpact(handleApiResponse(data)); + }, + getPortalToken: async (id: string | number): Promise => { const data = await apiGet(`${API_PATHS.rest.vendors}/${id}/portal-token`); return mapVendorPortalToken(handleApiResponse(data)); diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 569fba52..5cfec245 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -1,13 +1,18 @@ import type { Vendor, + VendorDeactivationImpact, VendorFacetCompany, VendorFacetJobBucket, VendorFacetLocation, VendorFacets, VendorListItem, + VendorOpenWorkOrderSummary, VendorPortalToken, + VendorPreferredContact, } from "@/domain/vendors/types/vendor"; +const PREFERRED_CONTACT_VALUES = new Set(["Phone", "Email", "Text"]); + function asRecord(raw: unknown): Record { return typeof raw === "object" && raw !== null ? (raw as Record) : {}; } @@ -39,6 +44,41 @@ function readId(record: Record): string | number { return ""; } +function readCompanyId(record: Record): string | number | null { + const value = record.companyId ?? record.CompanyId; + if (typeof value === "string") return value.trim() === "" ? null : value; + if (typeof value === "number" && Number.isFinite(value)) return value; + return null; +} + +function readPreferredContact( + record: Record, + ...keys: string[] +): VendorPreferredContact { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + const normalized = value.trim(); + if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) { + return normalized as VendorPreferredContact; + } + const lowered = normalized.toLowerCase(); + if (lowered === "phone" || lowered === "call") return "Phone"; + if (lowered === "email" || lowered === "e-mail") return "Email"; + if (lowered === "text" || lowered === "sms") return "Text"; + } + } + return "Phone"; +} + +export function toCanonicalPhone(value: unknown): string { + const digits = String(value ?? "") + .replace(/\D+/g, "") + .slice(0, 10); + if (digits.length !== 10) return ""; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + function readBool(record: Record, ...keys: string[]): boolean { for (const key of keys) { const value = record[key]; @@ -51,11 +91,12 @@ export function mapVendorListItem(raw: unknown): VendorListItem { const item = asRecord(raw); return { id: readId(item), + companyId: readCompanyId(item), companyName: readString(item, "companyName", "CompanyName"), contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), - phone: readString(item, "phone", "Phone"), - companyPhone: readString(item, "companyPhone", "CompanyPhone"), + phone: toCanonicalPhone(item.phone ?? item.Phone), + companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), @@ -63,6 +104,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem { state: readString(item, "state", "State"), tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), isActive: readBool(item, "isActive", "IsActive"), + preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), }; } @@ -74,11 +116,12 @@ export function mapVendor(raw: unknown): Vendor { const item = asRecord(raw); return { id: readId(item) || undefined, + companyId: readCompanyId(item), companyName: readString(item, "companyName", "CompanyName"), contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), - phone: readString(item, "phone", "Phone"), - companyPhone: readString(item, "companyPhone", "CompanyPhone"), + phone: toCanonicalPhone(item.phone ?? item.Phone), + companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), @@ -88,25 +131,29 @@ export function mapVendor(raw: unknown): Vendor { zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), isActive: readBool(item, "isActive", "IsActive"), + preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), }; } export function mapVendorToBackend(vendor: unknown): Record { const item = asRecord(vendor); + const companyName = readString(item, "companyName", "CompanyName"); const payload: Record = { - companyName: item.companyName ?? "", - contactName: item.contactName ?? "", - email: item.email ?? "", - phone: item.phone ?? "", - companyPhone: item.companyPhone ?? "", - googleMapsUrl: item.googleMapsUrl ?? "", - notes: item.notes ?? "", - address: item.address ?? "", - city: item.city ?? "", - state: item.state ?? "", - zip: item.zip ?? "", - tradeSpecialties: item.tradeSpecialties ?? "", - isActive: item.isActive ?? true, + companyId: readCompanyId(item), + companyName, + contactName: readString(item, "contactName", "ContactName"), + email: readString(item, "email", "Email"), + phone: toCanonicalPhone(item.phone ?? item.Phone), + companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), + notes: readString(item, "notes", "Notes"), + address: readString(item, "address", "Address"), + city: readString(item, "city", "City"), + state: readString(item, "state", "State"), + zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), + tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), + isActive: readBool(item, "isActive", "IsActive"), + preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"), }; const id = item.id ?? item.Id; if (typeof id === "string" || typeof id === "number") { @@ -118,8 +165,9 @@ export function mapVendorToBackend(vendor: unknown): Record { function mapFacetCompany(raw: unknown): VendorFacetCompany { const item = asRecord(raw); return { + companyId: readCompanyId(item), name: readString(item, "name", "Name"), - companyPhone: readString(item, "companyPhone", "CompanyPhone"), + companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), email: readString(item, "email", "Email"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), @@ -182,3 +230,32 @@ export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null { lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined, }; } + +function mapOpenWorkOrder(raw: unknown): VendorOpenWorkOrderSummary { + const item = asRecord(raw); + const workOrderId = item.workOrderId ?? item.WorkOrderId; + const dispatchId = item.dispatchId ?? item.DispatchId; + return { + workOrderId: + typeof workOrderId === "string" || typeof workOrderId === "number" ? workOrderId : "", + workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"), + workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle"), + status: readString(item, "status", "Status"), + scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined, + dispatchId: + typeof dispatchId === "string" || typeof dispatchId === "number" ? dispatchId : undefined, + }; +} + +export function mapVendorDeactivationImpact(raw: unknown): VendorDeactivationImpact { + const item = asRecord(raw); + const openWorkOrdersRaw = item.openWorkOrders ?? item.OpenWorkOrders; + const rawId = item.vendorId ?? item.VendorId ?? item.id ?? item.Id; + const vendorId: string | number = + typeof rawId === "string" || typeof rawId === "number" ? rawId : ""; + return { + vendorId, + canDeactivate: readBool(item, "canDeactivate", "CanDeactivate"), + openWorkOrders: Array.isArray(openWorkOrdersRaw) ? openWorkOrdersRaw.map(mapOpenWorkOrder) : [], + }; +} diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 3425730f..52d8edf3 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + VENDOR_PREFERRED_CONTACT_VALUES, + type VendorPreferredContact, +} from "@/domain/vendors/types/vendor"; const httpsUrl = z .string() @@ -8,12 +12,19 @@ const httpsUrl = z "Google Maps URL must use HTTPS", ); +const canonicalPhone = z + .string() + .refine( + (value) => value === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(value), + "Enter a 10-digit phone number", + ); + export const vendorSchema = z.object({ companyName: z.string().min(1, "Company is required"), contactName: z.string().min(1, "Technician name is required"), email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(), - phone: z.string().optional(), - companyPhone: z.string().optional(), + phone: canonicalPhone.optional(), + companyPhone: canonicalPhone.optional(), googleMapsUrl: httpsUrl.optional(), notes: z.string().optional(), totalJobs: z.number().optional(), @@ -23,6 +34,10 @@ export const vendorSchema = z.object({ zip: z.string().optional(), tradeSpecialties: z.string().optional(), isActive: z.boolean().optional(), + companyId: z.union([z.string(), z.number()]).nullable().optional(), + preferredContact: z + .enum(VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]]) + .optional(), }); export type VendorFormValues = z.infer; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index ffc67faa..6c67847c 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -1,5 +1,10 @@ +export type VendorPreferredContact = "Phone" | "Email" | "Text"; + +export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"]; + export interface VendorListItem { id: string | number; + companyId: string | number | null; companyName: string; contactName: string; email: string; @@ -12,10 +17,12 @@ export interface VendorListItem { state: string; tradeSpecialties: string; isActive: boolean; + preferredContact: VendorPreferredContact; } export interface Vendor { id?: string | number; + companyId: string | number | null; companyName: string; contactName: string; email: string; @@ -30,6 +37,7 @@ export interface Vendor { zip: string; tradeSpecialties: string; isActive: boolean; + preferredContact: VendorPreferredContact; } export interface VendorsListParams { @@ -51,6 +59,7 @@ export interface VendorsListResult { } export interface VendorFacetCompany { + companyId: string | number | null; name: string; companyPhone: string; email: string; @@ -85,3 +94,18 @@ export interface VendorPortalToken { expiresAt: string; lastUsedAt?: string; } + +export interface VendorOpenWorkOrderSummary { + workOrderId: string | number; + workOrderNumber: string; + workOrderTitle: string; + status: string; + scheduledDate?: string; + dispatchId?: string | number; +} + +export interface VendorDeactivationImpact { + vendorId: string | number; + canDeactivate: boolean; + openWorkOrders: VendorOpenWorkOrderSummary[]; +} diff --git a/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts b/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts new file mode 100644 index 00000000..7c59c970 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts @@ -0,0 +1,22 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import type { VendorDeactivationImpact } from "@/domain/vendors/types/vendor"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useVendorDeactivationImpact( + id: string | number | undefined, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.vendors.deactivationImpact(id ?? ""), + queryFn: () => vendorsApi.getDeactivationImpact(requireParam(id)), + enabled: id !== undefined && id !== null && id !== "", + staleTime: 0, + }); +} + +function requireParam(id: string | number | undefined): string | number { + if (id === undefined || id === null || id === "") { + throw new Error("Vendor id is required"); + } + return id; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index bc65dfe2..4548791b 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -59,6 +59,8 @@ export const queryKeys = { facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const, portalToken: (vendorId: string | number) => [...queryKeys.vendors.all, "portalToken", vendorId] as const, + deactivationImpact: (vendorId: string | number) => + [...queryKeys.vendors.all, "deactivationImpact", vendorId] as const, }, calendar: { all: ["calendar"] as const, diff --git a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx index eb197115..24d4c790 100644 --- a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx @@ -1,7 +1,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm, type FieldErrors } from "react-hook-form"; +import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { describe, expect, it } from "vitest"; import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields"; import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; @@ -10,6 +10,7 @@ import { renderWithProviders } from "@/test/test-utils"; const companies: VendorFacetCompany[] = [ { + companyId: "co-1", name: "Gateway Plumbing", companyPhone: "(314) 555-0198", email: "dispatch@gatewayplumbing.com", @@ -36,6 +37,8 @@ const emptyForm: VendorFormValues = { zip: "", tradeSpecialties: "", isActive: true, + companyId: null, + preferredContact: "Phone", }; function Harness({ @@ -52,6 +55,7 @@ function Harness({ const onCompanySelected = (company: VendorFacetCompany | null) => { if (!company) return; + setValue("companyId", company.companyId); setValue("companyPhone", company.companyPhone); setValue("email", company.email); setValue("address", company.address); @@ -61,6 +65,8 @@ function Harness({ setValue("googleMapsUrl", company.googleMapsUrl); }; + const companyId = useWatch({ control, name: "companyId" }); + return ( <> + {String(companyId ?? "")} @@ -78,6 +85,7 @@ function Harness({ } const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement; +const companyIdOutput = () => screen.getByTestId("company-id"); describe("VendorFormFields company autocomplete state", () => { it("clears a previously typed company when the form is reset (create-form reopen)", async () => { @@ -148,4 +156,50 @@ describe("VendorFormFields company autocomplete state", () => { expect(companyField()).toHaveValue("Gateway Plumbing"); expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198"); }); + + it("sets companyId when a known company is selected and clears it when the text changes", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Gate"); + await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" })); + expect(companyIdOutput()).toHaveTextContent("co-1"); + + await userEvent.type(companyField(), "!"); + expect(companyIdOutput()).toHaveTextContent(""); + }); + + it("keeps companyId null for free-typed company text that matches no option", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await userEvent.type(companyField(), "Independent Vendor LLC"); + expect(companyIdOutput()).toHaveTextContent(""); + }); + + it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => { + renderWithProviders(, { + withAuth: false, + }); + + const phone = screen.getByRole("textbox", { name: "Technician Phone" }); + await userEvent.type(phone, "3145550198"); + expect(phone).toHaveValue("(314) 555-0198"); + + const companyPhone = screen.getByRole("textbox", { name: "Company Phone" }); + await userEvent.type(companyPhone, "3145550100"); + expect(companyPhone).toHaveValue("(314) 555-0100"); + }); + + it("exposes a preferred contact selector with Phone, Email, and Text options", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument(); + }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 1db80de8..995dd5dc 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -5,12 +5,22 @@ import VendorsListPage from "@/app/(protected)/vendors/index"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); +const useVendorDetail = vi.fn(); +const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); +vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({ + useVendorDetail: (...args: unknown[]) => useVendorDetail(...args), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({ + useVendorDeactivationImpact: (...args: unknown[]) => useVendorDeactivationImpact(...args), +})); + vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({ useDeleteVendor: () => ({ mutate, isPending: false }), })); @@ -21,6 +31,7 @@ vi.mock("@/hooks/use-debounce", () => ({ const activeVendor = { id: 1, + companyId: "co-1", companyName: "Gateway Plumbing", contactName: "Adam Whyte", email: "dispatch@gatewayplumbing.com", @@ -29,6 +40,7 @@ const activeVendor = { state: "MO", tradeSpecialties: "Backflow Preventers", isActive: true, + preferredContact: "Email", }; const inactiveVendor = { @@ -47,8 +59,18 @@ function result(items: (typeof activeVendor)[], totalCount: number) { }; } +function setupDefaults() { + useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null }); + useVendorDeactivationImpact.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + }); +} + describe("VendorsListPage", () => { 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); return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1); @@ -67,6 +89,7 @@ describe("VendorsListPage", () => { }); it("reports the filtered totalCount in the footer when page rows are fewer than total", () => { + setupDefaults(); const pageVendors = Array.from({ length: 3 }, (_, index) => ({ ...activeVendor, id: 100 + index, @@ -83,4 +106,58 @@ describe("VendorsListPage", () => { 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(); + useVendorDetail.mockReturnValue({ data: activeVendor, 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 }); + + const row = screen.getByRole("button", { + name: "Open vendor details for Gateway Plumbing", + }); + row.focus(); + await userEvent.keyboard("{Enter}"); + + expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); + }); + + it("blocks deactivation when the preflight reports open work orders", async () => { + setupDefaults(); + useVendorDeactivationImpact.mockReturnValue({ + data: { + vendorId: 1, + canDeactivate: false, + openWorkOrders: [ + { + workOrderId: 101, + workOrderNumber: "WO-101", + workOrderTitle: "Boiler repair", + status: "Dispatched", + }, + ], + }, + 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: "Deactivate vendor" })); + + expect( + screen.getByText(/cannot be deactivated because it still has open work orders/), + ).toBeInTheDocument(); + expect(screen.getByText(/Boiler repair/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); + expect(mutate).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index 554ffcc6..e368c3dc 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -4,12 +4,13 @@ import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); const apiPost = vi.fn(); const apiPut = vi.fn(); +const apiDelete = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), apiPost: (...args: unknown[]) => apiPost(...args), apiPut: (...args: unknown[]) => apiPut(...args), - apiDelete: vi.fn(), + apiDelete: (...args: unknown[]) => apiDelete(...args), })); import { vendorsApi } from "@/domain/vendors/api/vendors-api"; @@ -19,15 +20,18 @@ describe("vendorsApi.getList", () => { apiGet.mockReset(); apiPost.mockReset(); apiPut.mockReset(); + apiDelete.mockReset(); apiGet.mockResolvedValue({ Data: [ { Id: 7, + CompanyId: "co-1", CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", City: "St. Louis", State: "MO", IsActive: false, + PreferredContact: "Email", }, ], TotalCount: 1, @@ -40,7 +44,13 @@ describe("vendorsApi.getList", () => { expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, { searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, }); - expect(result.items[0]).toMatchObject({ city: "St. Louis", state: "MO", isActive: false }); + expect(result.items[0]).toMatchObject({ + city: "St. Louis", + state: "MO", + isActive: false, + companyId: "co-1", + preferredContact: "Email", + }); }); it("forwards all selected facets as repeated indexed query parameters", async () => { @@ -91,7 +101,7 @@ describe("vendorsApi.getList", () => { expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} }); expect(facets.companies[0]).toMatchObject({ name: "Gateway Plumbing", - companyPhone: "314-555-0100", + companyPhone: "(314) 555-0100", googleMapsUrl: "https://maps.google.com/gateway", }); expect(facets.locations[0]?.label).toBe("St. Louis, MO"); @@ -116,6 +126,7 @@ describe("vendorsApi.getList", () => { it("sends all editable prototype fields when creating and updating", async () => { const values = { + companyId: "co-1", companyName: "Gateway Plumbing", contactName: "Adam Whyte", phone: "314-555-0198", @@ -130,6 +141,7 @@ describe("vendorsApi.getList", () => { notes: "Preferred after-hours vendor", totalJobs: 72, isActive: true, + preferredContact: "Text", }; apiPost.mockResolvedValueOnce({ data: values }); apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } }); @@ -139,7 +151,14 @@ describe("vendorsApi.getList", () => { expect(apiPost).toHaveBeenCalledWith( API_PATHS.rest.vendors, - expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes }), + expect.objectContaining({ + companyName: "Gateway Plumbing", + notes: values.notes, + companyId: "co-1", + preferredContact: "Text", + phone: "(314) 555-0198", + companyPhone: "(314) 555-0100", + }), ); expect(apiPut).toHaveBeenCalledWith( `${API_PATHS.rest.vendors}/7`, @@ -148,4 +167,67 @@ describe("vendorsApi.getList", () => { expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs"); }); + + it("sends a null companyId when free-text company has no company link", async () => { + apiPost.mockResolvedValueOnce({ data: {} }); + await vendorsApi.create({ + companyName: "Independent Vendor LLC", + contactName: "Indy Tech", + preferredContact: "Phone", + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ companyName: "Independent Vendor LLC", companyId: null }), + ); + }); + + it("maps the deactivation-impact payload including linked work orders", async () => { + apiGet.mockResolvedValueOnce({ + data: { + vendorId: 7, + canDeactivate: false, + openWorkOrders: [ + { + workOrderId: 101, + workOrderNumber: "WO-101", + workOrderTitle: "Boiler repair", + status: "Dispatched", + scheduledDate: "2025-01-02", + dispatchId: 501, + }, + ], + }, + }); + + const impact = await vendorsApi.getDeactivationImpact(7); + + expect(apiGet).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7/${API_PATHS.vendor.deactivationImpact}`, + ); + expect(impact).toEqual({ + vendorId: 7, + canDeactivate: false, + openWorkOrders: [ + { + workOrderId: 101, + workOrderNumber: "WO-101", + workOrderTitle: "Boiler repair", + status: "Dispatched", + scheduledDate: "2025-01-02", + dispatchId: 501, + }, + ], + }); + }); + + it("deletes a vendor by id and surfaces the 409 error without retry swallowing", async () => { + apiDelete.mockResolvedValueOnce(undefined); + await vendorsApi.delete(7); + expect(apiDelete).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`); + + const conflict = Object.assign(new Error("Conflict"), { response: { status: 409 } }); + apiDelete.mockRejectedValueOnce(conflict); + await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict"); + }); }); diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index 2c53d784..aee36aaa 100644 --- a/src/test/domain/vendors/schemas/vendor-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -5,6 +5,7 @@ const validVendor = { companyName: "Gateway Plumbing", contactName: "Adam Whyte", email: "dispatch@gateway.test", + phone: "(314) 555-0198", googleMapsUrl: "https://maps.google.com/gateway", }; @@ -33,4 +34,28 @@ describe("vendorSchema", () => { }).success, ).toBe(false); }); + + it("accepts empty phones and canonical (XXX) XXX-XXXX phones, rejecting partials", () => { + expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe( + true, + ); + expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe( + true, + ); + expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(false); + expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false); + }); + + it("accepts a nullable companyId and a known preferred contact value", () => { + expect( + vendorSchema.safeParse({ ...validVendor, companyId: null, preferredContact: "Text" }).success, + ).toBe(true); + expect( + vendorSchema.safeParse({ ...validVendor, companyId: 42, preferredContact: "Email" }).success, + ).toBe(true); + }); + + it("rejects an unknown preferred contact value", () => { + expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false); + }); }); From 922d6c9a57d077f6d422136fc24af62a3764ca2d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 17:44:53 -0300 Subject: [PATCH 03/10] feat(vendors): edit assigned vendor from work order --- e2e/work-orders/vendor-inline-editor.spec.ts | 90 +++++++ src/app/(protected)/workorders/[id].tsx | 225 ++++++++++++++++++ src/domain/work-orders/api/work-orders-api.ts | 10 +- .../work-orders/mappers/work-order-mapper.ts | 2 + src/domain/work-orders/types/dispatch.ts | 11 + src/domain/work-orders/types/work-order.ts | 1 + .../use-cases/use-work-order-mutations.ts | 26 ++ .../work-orders/api/work-orders-api.test.ts | 19 ++ .../mappers/work-order-mapper.test.ts | 23 +- 9 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 e2e/work-orders/vendor-inline-editor.spec.ts diff --git a/e2e/work-orders/vendor-inline-editor.spec.ts b/e2e/work-orders/vendor-inline-editor.spec.ts new file mode 100644 index 00000000..95639b66 --- /dev/null +++ b/e2e/work-orders/vendor-inline-editor.spec.ts @@ -0,0 +1,90 @@ +import { expect, test, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "work-order-vendor-e2e-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +test("edits only the vendor assigned to the current work order", async ({ page }) => { + let updateRequest: { url: string; body: Record } | undefined; + + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + Id: 501, + InternalWONumber: "WO-501", + WorkerOrderNumber: "CUSTOMER-501", + WorkerOrderTitle: "Emergency boiler repair", + Status: "Dispatched", + Comments: [], + AuditLog: [], + Dispatches: [ + { + Id: 801, + DispatchNumber: "D-801", + VendorId: 7, + VendorName: "Gateway Plumbing", + VendorEmail: "dispatch@gateway.test", + Status: "Sent", + DispatchedAt: "2026-07-23T12:00:00Z", + }, + ], + }), + ); + await page.route("**/api/vendors/7/work-order-update", async (route) => { + updateRequest = { + url: route.request().url(), + body: route.request().postDataJSON() as Record, + }; + await fulfillJson(route, { data: { id: 7 } }); + }); + await page.route("**/api/vendors/7", (route) => + fulfillJson(route, { + data: { + Id: 7, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + PreferredContact: "Email", + Phone: "3145550198", + Email: "dispatch@gateway.test", + Notes: "Preferred after-hours vendor", + IsActive: true, + }, + }), + ); + await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] })); + + await page.goto("/workorders/501"); + await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible(); + await page.getByRole("button", { name: "Edit vendor" }).click(); + + await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible(); + await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte"); + await page.getByLabel("Technician name").fill("Taylor Tech"); + await page.getByLabel("Preferred contact").click(); + await page.getByRole("option", { name: "Text" }).click(); + await page.getByLabel("Phone").fill("3145550111"); + await page.getByLabel("Email").fill("taylor@example.com"); + await page.getByLabel("Notes").fill("Text before arrival"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden(); + expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/); + expect(updateRequest?.body).toEqual({ + workOrderId: 501, + contactName: "Taylor Tech", + preferredContact: "Text", + phone: "(314) 555-0111", + email: "taylor@example.com", + notes: "Text before arrival", + }); +}); diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index a282040c..610148a2 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -10,6 +10,10 @@ import { Button, Chip, CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Link, Menu, MenuItem, @@ -36,8 +40,11 @@ import { useAddWorkOrderComment, useChangeWorkOrderAssignment, useChangeWorkOrderStatus, + useUpdateWorkOrderVendor, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import type { VendorPreferredContact } from "@/domain/vendors/types/vendor"; import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; const STATUS_COLORS: Record< @@ -53,6 +60,31 @@ const STATUS_COLORS: Record< type ActivityTab = "customer" | "internal" | "audit"; +interface VendorEditorState { + vendorId: string | number; + vendorName: string; + contactName: string; + preferredContact: VendorPreferredContact; + phone: string; + email: string; + notes: string; +} + +function formatPhoneMask(input: string): string { + const digits = input.replace(/\D/g, "").slice(0, 10); + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + +function isVendorEditorValid(editor: VendorEditorState): boolean { + const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone); + const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email); + return ( + phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200 + ); +} + function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) { const sorted = useMemo( () => @@ -167,6 +199,7 @@ export default function WorkOrderViewPage() { const statusMutation = useChangeWorkOrderStatus(workOrderId); const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); const commentMutation = useAddWorkOrderComment(workOrderId); + const vendorMutation = useUpdateWorkOrderVendor(workOrderId); const [activeTab, setActiveTab] = useState("customer"); const [commentText, setCommentText] = useState(""); @@ -174,6 +207,9 @@ export default function WorkOrderViewPage() { const [dispatchDetailId, setDispatchDetailId] = useState(null); const [statusAnchor, setStatusAnchor] = useState(null); const [assignAnchor, setAssignAnchor] = useState(null); + const [vendorEditor, setVendorEditor] = useState(null); + const [vendorEditorLoading, setVendorEditorLoading] = useState(false); + const [vendorEditorError, setVendorEditorError] = useState(""); const customerComments = useMemo( () => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"), @@ -200,6 +236,74 @@ export default function WorkOrderViewPage() { ); }; + const openVendorEditor = async ( + event: React.MouseEvent, + vendorId: string | number, + vendorName: string, + ) => { + event.stopPropagation(); + setVendorEditorError(""); + setVendorEditorLoading(true); + setVendorEditor({ + vendorId, + vendorName, + contactName: "", + preferredContact: "Phone", + phone: "", + email: "", + notes: "", + }); + + try { + const vendor = await vendorsApi.getById(vendorId); + setVendorEditor({ + vendorId, + vendorName: vendor.companyName || vendorName, + contactName: vendor.contactName, + preferredContact: vendor.preferredContact, + phone: vendor.phone, + email: vendor.email, + notes: vendor.notes, + }); + } catch (vendorError) { + setVendorEditorError( + vendorError instanceof Error ? vendorError.message : "Unable to load vendor details", + ); + } finally { + setVendorEditorLoading(false); + } + }; + + const saveVendorEditor = () => { + if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return; + const numericWorkOrderId = Number(workOrderId); + if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { + setVendorEditorError("A valid work order is required"); + return; + } + + vendorMutation.mutate( + { + vendorId: vendorEditor.vendorId, + payload: { + workOrderId: numericWorkOrderId, + contactName: vendorEditor.contactName.trim(), + preferredContact: vendorEditor.preferredContact, + phone: vendorEditor.phone, + email: vendorEditor.email.trim(), + notes: vendorEditor.notes.trim(), + }, + }, + { + onSuccess: () => { + setVendorEditor(null); + void refetch(); + }, + onError: (vendorError) => setVendorEditorError(vendorError.message), + }, + ); + }; + if (isLoading) { return ( @@ -426,6 +530,7 @@ export default function WorkOrderViewPage() { Status Date Wait + Actions @@ -465,6 +570,17 @@ export default function WorkOrderViewPage() { "—" )} + + + ); })} @@ -550,6 +666,115 @@ export default function WorkOrderViewPage() { onUpdated={() => void refetch()} /> )} + { + if (!vendorMutation.isPending) setVendorEditor(null); + }} + fullWidth + maxWidth="sm" + > + Edit assigned vendor + + + + {vendorEditor?.vendorName} + + {vendorEditorError && {vendorEditorError}} + {vendorEditorLoading ? ( + + + + ) : ( + <> + + setVendorEditor((current) => + current ? { ...current, contactName: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + + setVendorEditor((current) => + current + ? { + ...current, + preferredContact: event.target.value as VendorPreferredContact, + } + : current, + ) + } + > + Phone + Email + Text + + + setVendorEditor((current) => + current + ? { ...current, phone: formatPhoneMask(event.target.value) } + : current, + ) + } + error={Boolean( + vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone), + )} + helperText="Use (XXX) XXX-XXXX" + /> + + setVendorEditor((current) => + current ? { ...current, email: event.target.value } : current, + ) + } + /> + + setVendorEditor((current) => + current ? { ...current, notes: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + )} + + + + + + +
    ); } diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index c17f207d..a3ede840 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiDelete, apiGet, apiPost } from "@/api/api"; +import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { isWoTableMockEnabled, @@ -37,6 +37,7 @@ import type { LocationOption, TaskListTemplate, UpdateDispatchPayload, + WorkOrderVendorUpdatePayload, VendorDropdownItem, WorkOrderComment, WorkOrderDetail, @@ -294,6 +295,13 @@ export const workOrdersApi = { await apiPost(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload)); }, + updateVendorFromWorkOrder: async ( + vendorId: string | number, + payload: WorkOrderVendorUpdatePayload, + ): Promise => { + await apiPut(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload); + }, + addDispatchComment: async ( dispatchId: string | number, text: string, diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 687aa3b1..c424c26e 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -250,9 +250,11 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm return { id: readId(item), + vendorId: readId({ id: item.vendorId ?? item.VendorId }), dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"), poNumber: readString(item, "poNumber", "PoNumber", "PONumber"), vendorName: readString(item, "vendorName", "VendorName"), + vendorEmail: readString(item, "vendorEmail", "VendorEmail"), nteAmount: readNumber(item, "nteAmount", "NTEAmount"), status: readString(item, "status", "Status"), dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"), diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts index d5df044c..fdb0175c 100644 --- a/src/domain/work-orders/types/dispatch.ts +++ b/src/domain/work-orders/types/dispatch.ts @@ -1,13 +1,24 @@ export interface WorkOrderDispatchSummary { id: string | number; + vendorId: string | number; dispatchNumber: string; poNumber: string; vendorName: string; + vendorEmail: string; nteAmount: number | null; status: string; dispatchedAt: string; } +export interface WorkOrderVendorUpdatePayload { + workOrderId: number; + contactName?: string; + preferredContact: "Phone" | "Email" | "Text"; + phone?: string; + email?: string; + notes?: string; +} + export interface DispatchChecklistItem { id: string | number; itemText: string; diff --git a/src/domain/work-orders/types/work-order.ts b/src/domain/work-orders/types/work-order.ts index c1440bff..f266bc6a 100644 --- a/src/domain/work-orders/types/work-order.ts +++ b/src/domain/work-orders/types/work-order.ts @@ -7,6 +7,7 @@ export type { DispatchWorkOrderRef, UpdateDispatchPayload, WorkOrderDispatchSummary, + WorkOrderVendorUpdatePayload, } from "./dispatch"; export type { diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index ed877ab5..5c69e3a4 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -11,6 +11,7 @@ import type { WorkOrderComment, WorkOrderDetail, WorkOrderFormValues, + WorkOrderVendorUpdatePayload, } from "@/domain/work-orders/types/work-order"; import { queryKeys } from "@/infra/query-key/query-key"; @@ -46,6 +47,11 @@ interface ChangeWorkOrderAssignmentInput { userName: string; } +interface UpdateWorkOrderVendorInput { + vendorId: string | number; + payload: WorkOrderVendorUpdatePayload; +} + export function useCreateWorkOrder(): UseMutationResult< WorkOrderDetail, Error, @@ -89,6 +95,26 @@ export function useUpdateWorkOrder( }); } +export function useUpdateWorkOrderVendor( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ vendorId, payload }) => + workOrdersApi.updateVendorFromWorkOrder(vendorId, payload), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); + toast.success("Vendor contact updated"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update vendor contact"); + }, + }); +} + export function useAddWorkOrderComment( workOrderId: string | number, ): UseMutationResult { diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 32040a19..f44e7b30 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); +const apiPut = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), + apiPut: (...args: unknown[]) => apiPut(...args), apiPost: vi.fn(), apiDelete: vi.fn(), apiPostNoContent: vi.fn(), @@ -15,6 +17,7 @@ import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; describe("workOrdersApi.getById", () => { beforeEach(() => { apiGet.mockReset(); + apiPut.mockReset(); apiGet.mockResolvedValue({ Id: 10, title: "Test WO", @@ -22,6 +25,22 @@ describe("workOrdersApi.getById", () => { }); }); + it("updates only the assigned vendor through the work-order-scoped endpoint", async () => { + apiPut.mockResolvedValueOnce(undefined); + const payload = { + workOrderId: 10, + contactName: "Taylor Tech", + preferredContact: "Text" as const, + phone: "(314) 555-0199", + email: "taylor@example.com", + notes: "Text before arrival", + }; + + await workOrdersApi.updateVendorFromWorkOrder(7, payload); + + expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7/work-order-update`, payload); + }); + it("calls GetWorkorderById with id query param, not path suffix", async () => { await workOrdersApi.getById(10); 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 a9dfc9bf..632fa346 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 @@ -1,5 +1,26 @@ import { describe, expect, it } from "vitest"; -import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper"; +import { + mapUpliftRequest, + mapWorkOrderDispatchSummary, +} from "@/domain/work-orders/mappers/work-order-mapper"; + +describe("mapWorkOrderDispatchSummary", () => { + it("preserves the assigned vendor identity needed by inline editing", () => { + expect( + mapWorkOrderDispatchSummary({ + Id: 21, + VendorId: 7, + VendorName: "Gateway Plumbing", + VendorEmail: "dispatch@gateway.test", + }), + ).toMatchObject({ + id: 21, + vendorId: 7, + vendorName: "Gateway Plumbing", + vendorEmail: "dispatch@gateway.test", + }); + }); +}); describe("mapUpliftRequest", () => { it("maps decided uplift fields from PascalCase payload", () => { From 21b7de57a123af9f289ab818575e2b03a666ca88 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 18:48:15 -0300 Subject: [PATCH 04/10] chore(frontend): enforce maintainable text rendering --- docs/FRONTEND_MAINTAINABILITY.md | 66 +++++++++++++ eslint.config.js | 20 ++++ .../(auth)/_components/auth-card-header.tsx | 18 ++-- src/app/(auth)/login.tsx | 9 +- src/app/v/[token]/_layout.tsx | 11 ++- src/app/v/[token]/dashboard.tsx | 7 +- src/app/v/[token]/dispatch/[id].tsx | 27 +++-- src/app/v/[token]/pos.tsx | 7 +- src/components/ui/page-header.tsx | 28 +++--- src/components/ui/text.tsx | 99 +++++++++++++++++++ src/test/components/ui/text.test.tsx | 44 +++++++++ 11 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 docs/FRONTEND_MAINTAINABILITY.md create mode 100644 src/components/ui/text.tsx create mode 100644 src/test/components/ui/text.test.tsx diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md new file mode 100644 index 00000000..cd7863da --- /dev/null +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -0,0 +1,66 @@ +# Frontend maintainability conventions + +## Conditional rendering + +Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty +state. Use a ternary only when both branches render meaningful alternatives. + +```tsx +{ + error && {error.message}; +} + + + {description} +; +``` + +ESLint rejects `condition ? : null`. This keeps one-sided conditions visually +distinct from real either-or UI decisions. + +## Typography and feedback + +Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, +code, and asynchronous feedback. It owns: + +- semantic HTML for each visual variant; +- the display, body, and monospace font families; +- default, muted, error, success, and warning tones; +- accessible `alert` and `status` live regions for error and feedback text; +- one-sided conditional text through `when`. + +ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, +but new shared UI should prefer `Text` so semantics and design tokens do not drift. + +## Forms and mutations + +Use the libraries already established in the application: + +- React Hook Form owns field registration, touched/dirty state, and client form lifecycle. +- Zod owns form validation and inferred form value types. +- TanStack Query owns server reads and mutations, including pending/error state, cache + invalidation, and retries where safe. + +Do not add TanStack Form alongside React Hook Form. It would create two form conventions without +removing any current dependency. Reconsider only as a deliberate repository-wide migration with +benchmarks, a codemod plan, and an approved deprecation path. + +File uploads are not ordinary form fields. Keep file selection and client validation in a focused +component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and +retry state. Do not place upload orchestration in a route-sized page component. + +## Page state + +Pages should compose focused state components instead of accumulating unrelated booleans: + +- query loading, error, and empty states stay adjacent to the query result; +- mutation pending/error state belongs to the component that initiated the mutation; +- route pages coordinate sections and navigation; +- reusable sections own their interaction details; +- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes. + +## Enforcement and rollout + +The lint rules are repository-wide and the initial violations were migrated in the same change. +`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future +maintainability rules must also land with a green migration rather than a warning-only backlog. diff --git a/eslint.config.js b/eslint.config.js index 8d1f29c2..f88e2a81 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,6 +62,26 @@ export default tseslint.config( "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + "no-restricted-syntax": [ + "error", + { + selector: + "JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]", + message: + "Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.", + }, + { + selector: + ":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])", + message: + "Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.", + }, + { + selector: + "JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']", + message: 'Use Text variant="error" for accessible, consistent error feedback.', + }, + ], }, }, { diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx index 795ca0a7..aaf95cf6 100644 --- a/src/app/(auth)/_components/auth-card-header.tsx +++ b/src/app/(auth)/_components/auth-card-header.tsx @@ -1,5 +1,6 @@ import type { ComponentPropsWithoutRef } from "react"; +import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & { @@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & { export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) { return (
    -

    {title}

    - {subtitle && ( -

    - {subtitle} -

    - )} + + {title} + + + {subtitle} +
    ); } diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx index 9149bc8d..7d1c4c74 100644 --- a/src/app/(auth)/login.tsx +++ b/src/app/(auth)/login.tsx @@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header"; import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell"; import { LoginForm } from "@/app/(auth)/_components/login-form"; import { BrandLockup } from "@/components/common/brand-lockup"; +import { Text } from "@/components/ui/text"; import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema"; import { useAuthContext } from "@/providers/auth-context"; @@ -37,9 +38,13 @@ export default function LoginPage() { isLoggingIn={isLoggingIn} loginError={loginError} /> -

    + Having trouble? Contact your administrator. -

    + ); } diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx index 548f4ed3..3b4e7880 100644 --- a/src/app/v/[token]/_layout.tsx +++ b/src/app/v/[token]/_layout.tsx @@ -1,6 +1,7 @@ import { NavLink, Outlet, useLocation, useParams } from "react-router"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider"; +import { Text } from "@/components/ui/text"; import "@/app/v/_components/vendor-portal.css"; function VendorPortalHeader() { @@ -55,13 +56,13 @@ function VendorPortalBody() { if (status === "error") { return (
    -
    -

    Access Denied

    -

    +

    + Access Denied + {error?.message || "This link is invalid or has expired. Please contact your dispatcher for a new link."} -

    -
    + +
    ); } diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx index a7dc74f9..eeb222a2 100644 --- a/src/app/v/[token]/dashboard.tsx +++ b/src/app/v/[token]/dashboard.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { Text } from "@/components/ui/text"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { formatVendorPortalDateTime, @@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() { return ( <>
    -

    Your Work

    + Your Work
    Active dispatches and work orders assigned to your company.
    @@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
    {isLoading &&
    Loading dispatches…
    } - {error &&
    {error.message}
    } + + {error?.message} + {!isLoading && !error && filtered.length === 0 && (
    No dispatches in this view.
    diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index 2edae8a1..854631b9 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { SignaturePad } from "@/app/v/_components/signature-pad"; import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; +import { Text } from "@/components/ui/text"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { formatVendorPortalDateTime, @@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() { } if (error && !data) { - return
    {error.message}
    ; + return ( + + {error.message} + + ); } if (!data) return null; @@ -457,7 +462,7 @@ export default function VendorPortalDispatchPage() {
    -

    {data.dispatchNumber}

    + {data.dispatchNumber}
    {data.workOrder?.workerOrderTitle}
    {data.status} @@ -492,7 +497,9 @@ export default function VendorPortalDispatchPage() { {data.location && ( <> -

    Location

    + + Location +
    {data.location.name}
    {[ @@ -509,7 +516,9 @@ export default function VendorPortalDispatchPage() { {(data.description || data.workOrder?.description) && ( <> -

    Description

    + + Description +
    {data.description || data.workOrder?.description}
    )} @@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
    -

    Checklist

    + Checklist
    -

    NTE Uplift Requests

    + NTE Uplift Requests
    -

    Customer Signoff

    + Customer Signoff
    -

    Vendor Signoff

    + Vendor Signoff
    -

    Comments

    + Comments
    -

    Your Purchase Orders

    + Your Purchase Orders
    Every PO issued to your company, with current status and dollar value.
    @@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
    {isLoading &&
    Loading POs…
    } - {error &&
    {error.message}
    } + + {error?.message} + {!isLoading && !error && visible.length === 0 && (
    No POs match this view.
    diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index a43b99e3..dd691a80 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { Stack, Typography } from "@mui/material"; +import { Stack } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; type PageHeaderProps = { @@ -35,24 +36,25 @@ export function PageHeader({ }} > - {eyebrow && ( -

    - {eyebrow} -

    - )} -

    + {eyebrow} + + {title} -

    - {subtitle && ( - - {subtitle} - - )} + + + {subtitle} +
    {actions && ( diff --git a/src/components/ui/text.tsx b/src/components/ui/text.tsx new file mode 100644 index 00000000..2628d808 --- /dev/null +++ b/src/components/ui/text.tsx @@ -0,0 +1,99 @@ +import type { ElementType, ReactNode } from "react"; +import { + Typography as MuiTypography, + type TypographyProps as MuiTypographyProps, +} from "@mui/material"; + +type TextVariant = + | "display" + | "title" + | "heading" + | "body" + | "description" + | "label" + | "feedback" + | "error" + | "caption" + | "code"; + +type TextTone = "default" | "muted" | "error" | "success" | "warning"; +type TextFamily = "display" | "body" | "mono"; + +export type TextProps = Omit & { + children: ReactNode; + as?: ElementType; + family?: TextFamily; + tone?: TextTone; + variant?: TextVariant; + when?: boolean; +}; + +const variantConfig: Record< + TextVariant, + { element: ElementType; muiVariant: MuiTypographyProps["variant"]; family: TextFamily } +> = { + display: { element: "h1", muiVariant: "h3", family: "display" }, + title: { element: "h2", muiVariant: "h5", family: "display" }, + heading: { element: "h3", muiVariant: "h6", family: "display" }, + body: { element: "p", muiVariant: "body1", family: "body" }, + description: { element: "p", muiVariant: "body2", family: "body" }, + label: { element: "span", muiVariant: "subtitle2", family: "body" }, + feedback: { element: "p", muiVariant: "body2", family: "body" }, + error: { element: "p", muiVariant: "body2", family: "body" }, + caption: { element: "span", muiVariant: "caption", family: "body" }, + code: { element: "code", muiVariant: "body2", family: "mono" }, +}; + +const toneColor: Record = { + default: "text.primary", + muted: "text.secondary", + error: "error", + success: "success.main", + warning: "warning.main", +}; + +const familyValue: Record = { + display: "var(--font-display)", + body: "var(--font-sans)", + mono: "var(--font-mono)", +}; + +export function Text({ + as, + children, + family, + tone, + variant = "body", + when = true, + sx, + ...props +}: TextProps) { + if (!when) { + return null; + } + + const config = variantConfig[variant]; + const resolvedTone = tone ?? (variant === "error" ? "error" : "default"); + let liveProps = {}; + if (variant === "error") { + liveProps = { role: "alert", "aria-live": "assertive" as const }; + } else if (variant === "feedback") { + liveProps = { role: "status", "aria-live": "polite" as const }; + } + + return ( + + {children} + + ); +} diff --git a/src/test/components/ui/text.test.tsx b/src/test/components/ui/text.test.tsx new file mode 100644 index 00000000..59b4ce93 --- /dev/null +++ b/src/test/components/ui/text.test.tsx @@ -0,0 +1,44 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Text } from "@/components/ui/text"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("Text", () => { + it("maps visual variants to semantic elements", () => { + renderWithProviders( + <> + Page title + Supporting copy + , + { withAuth: false }, + ); + + expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument(); + expect(screen.getByText("Supporting copy").tagName).toBe("P"); + }); + + it("does not render conditional text when its condition is false", () => { + renderWithProviders( + + Saved + , + { withAuth: false }, + ); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("gives error and feedback messages accessible live-region semantics", () => { + renderWithProviders( + <> + Upload failed + Uploading + , + { withAuth: false }, + ); + + expect(screen.getByRole("alert")).toHaveTextContent("Upload failed"); + expect(screen.getByRole("status")).toHaveTextContent("Uploading"); + }); +}); From 6ca45bc0ae0083e513a2d7efafd844e0c4b194ca Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 19:17:14 -0300 Subject: [PATCH 05/10] fix(vendors): preserve phone data across edits --- e2e/vendors/vendors.spec.ts | 2 +- .../_components/vendor-form-fields.tsx | 4 +- src/domain/vendors/mappers/vendor-mapper.ts | 30 ++++++++----- src/domain/vendors/schemas/vendor-schema.ts | 15 +++---- .../domain/vendors/api/vendors-api.test.ts | 44 +++++++++++++++++-- .../vendors/schemas/vendor-schema.test.ts | 5 ++- 6 files changed, 73 insertions(+), 27 deletions(-) diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index a923336c..29df8921 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -289,7 +289,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByLabel("Technician Name").fill("New Technician"); 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.getByLabel("Company Phone")).toHaveValue("314-555-0100"); await expect(page.getByRole("textbox", { name: "Email" })).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"); diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx index 06fe52b3..a680d383 100644 --- a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx @@ -98,7 +98,7 @@ export function VendorFormFields({ label="Technician Phone" placeholder="(XXX) XXX-XXXX" inputMode="tel" - value={formatPhoneMask(field.value ?? "")} + value={field.value ?? ""} onChange={(event) => field.onChange(formatPhoneMask(event.target.value))} error={Boolean(errors.phone)} helperText={errors.phone?.message} @@ -208,7 +208,7 @@ export function VendorFormFields({ label="Company Phone" placeholder="(XXX) XXX-XXXX" inputMode="tel" - value={formatPhoneMask(field.value ?? "")} + value={field.value ?? ""} onChange={(event) => field.onChange(formatPhoneMask(event.target.value))} error={Boolean(errors.companyPhone)} helperText={errors.companyPhone?.message} diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 5cfec245..9273ef8b 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -51,6 +51,14 @@ function readCompanyId(record: Record): string | number | null return null; } +function readNumericCompanyId(record: Record): number | null { + const value = readCompanyId(record); + if (typeof value === "number") return value; + if (typeof value !== "string" || value.trim() === "") return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + function readPreferredContact( record: Record, ...keys: string[] @@ -72,10 +80,12 @@ function readPreferredContact( } export function toCanonicalPhone(value: unknown): string { - const digits = String(value ?? "") - .replace(/\D+/g, "") - .slice(0, 10); - if (digits.length !== 10) return ""; + const original = String(value ?? ""); + let digits = original.replace(/\D+/g, ""); + if (digits.length === 11 && digits.startsWith("1")) { + digits = digits.slice(1); + } + if (digits.length !== 10) return original; return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; } @@ -95,8 +105,8 @@ export function mapVendorListItem(raw: unknown): VendorListItem { companyName: readString(item, "companyName", "CompanyName"), contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), - phone: toCanonicalPhone(item.phone ?? item.Phone), - companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + phone: readString(item, "phone", "Phone"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), @@ -120,8 +130,8 @@ export function mapVendor(raw: unknown): Vendor { companyName: readString(item, "companyName", "CompanyName"), contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), - phone: toCanonicalPhone(item.phone ?? item.Phone), - companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + phone: readString(item, "phone", "Phone"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), @@ -139,7 +149,7 @@ export function mapVendorToBackend(vendor: unknown): Record { const item = asRecord(vendor); const companyName = readString(item, "companyName", "CompanyName"); const payload: Record = { - companyId: readCompanyId(item), + companyId: readNumericCompanyId(item), companyName, contactName: readString(item, "contactName", "ContactName"), email: readString(item, "email", "Email"), @@ -167,7 +177,7 @@ function mapFacetCompany(raw: unknown): VendorFacetCompany { return { companyId: readCompanyId(item), name: readString(item, "name", "Name"), - companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), email: readString(item, "email", "Email"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), diff --git a/src/domain/vendors/schemas/vendor-schema.ts b/src/domain/vendors/schemas/vendor-schema.ts index 52d8edf3..7ce3be5e 100644 --- a/src/domain/vendors/schemas/vendor-schema.ts +++ b/src/domain/vendors/schemas/vendor-schema.ts @@ -12,19 +12,18 @@ const httpsUrl = z "Google Maps URL must use HTTPS", ); -const canonicalPhone = z - .string() - .refine( - (value) => value === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(value), - "Enter a 10-digit phone number", - ); +const northAmericanPhone = z.string().refine((value) => { + if (value === "") return true; + const digits = value.replace(/\D+/g, ""); + return digits.length === 10 || (digits.length === 11 && digits.startsWith("1")); +}, "Enter a 10-digit phone number"); export const vendorSchema = z.object({ companyName: z.string().min(1, "Company is required"), contactName: z.string().min(1, "Technician name is required"), email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(), - phone: canonicalPhone.optional(), - companyPhone: canonicalPhone.optional(), + phone: northAmericanPhone.optional(), + companyPhone: northAmericanPhone.optional(), googleMapsUrl: httpsUrl.optional(), notes: z.string().optional(), totalJobs: z.number().optional(), diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index e368c3dc..b66ce1bf 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -101,7 +101,7 @@ describe("vendorsApi.getList", () => { expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.facets, { searchParams: {} }); expect(facets.companies[0]).toMatchObject({ name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", + companyPhone: "314-555-0100", googleMapsUrl: "https://maps.google.com/gateway", }); expect(facets.locations[0]?.label).toBe("St. Louis, MO"); @@ -126,10 +126,10 @@ describe("vendorsApi.getList", () => { it("sends all editable prototype fields when creating and updating", async () => { const values = { - companyId: "co-1", + companyId: "42", companyName: "Gateway Plumbing", contactName: "Adam Whyte", - phone: "314-555-0198", + phone: "+1 314-555-0198", companyPhone: "314-555-0100", email: "dispatch@gateway.test", address: "1 Market St", @@ -154,7 +154,7 @@ describe("vendorsApi.getList", () => { expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes, - companyId: "co-1", + companyId: 42, preferredContact: "Text", phone: "(314) 555-0198", companyPhone: "(314) 555-0100", @@ -182,6 +182,42 @@ describe("vendorsApi.getList", () => { ); }); + it("does not send a non-numeric company id to the integer backend contract", async () => { + apiPost.mockResolvedValueOnce({ data: {} }); + + await vendorsApi.create({ + companyId: "co-1", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.rest.vendors, + expect.objectContaining({ companyId: null }), + ); + }); + + it("preserves a legacy phone on read and unrelated write", async () => { + apiGet.mockResolvedValueOnce({ + data: { + id: 7, + companyName: "Legacy Vendor", + contactName: "Taylor", + phone: "555-1234 ext 9", + }, + }); + apiPut.mockResolvedValueOnce({ data: {} }); + + const vendor = await vendorsApi.getById(7); + expect(vendor.phone).toBe("555-1234 ext 9"); + + await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" }); + expect(apiPut).toHaveBeenCalledWith( + `${API_PATHS.rest.vendors}/7`, + expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }), + ); + }); + it("maps the deactivation-impact payload including linked work orders", async () => { apiGet.mockResolvedValueOnce({ data: { diff --git a/src/test/domain/vendors/schemas/vendor-schema.test.ts b/src/test/domain/vendors/schemas/vendor-schema.test.ts index aee36aaa..244c2b8e 100644 --- a/src/test/domain/vendors/schemas/vendor-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-schema.test.ts @@ -35,14 +35,15 @@ describe("vendorSchema", () => { ).toBe(false); }); - it("accepts empty phones and canonical (XXX) XXX-XXXX phones, rejecting partials", () => { + it("accepts canonicalizable North American phones and rejects partials", () => { expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe( true, ); expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe( true, ); - expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(false); + expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true); + expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true); expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false); }); From 392af77b911bbb4cf3b16e48dd514e2cb64f14aa Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 11:32:58 -0300 Subject: [PATCH 06/10] refactor(ui): enforce maintainable conditional feedback --- docs/FRONTEND_MAINTAINABILITY.md | 49 ++++++++++ eslint-rules/no-non-boolean-jsx-and.js | 95 +++++++++++++++++++ eslint.config.js | 15 ++- package.json | 4 +- src/app/(auth)/_components/login-form.tsx | 4 +- .../accounts/_components/account-form.tsx | 2 +- src/app/(protected)/accounts/index.tsx | 2 +- .../assets/_components/asset-form.tsx | 2 +- src/app/(protected)/assets/index.tsx | 2 +- .../calendar/_components/event-form.tsx | 2 +- .../contacts/_components/contact-form.tsx | 2 +- src/app/(protected)/contacts/index.tsx | 2 +- src/app/(protected)/dashboard.tsx | 4 +- .../employees/_components/employee-form.tsx | 4 +- src/app/(protected)/employees/index.tsx | 2 +- .../followups/_components/follow-up-form.tsx | 2 +- src/app/(protected)/followups/index.tsx | 2 +- src/app/(protected)/index.tsx | 4 +- .../locations/_components/location-form.tsx | 2 +- src/app/(protected)/locations/index.tsx | 2 +- .../_components/pm-schedule-form.tsx | 4 +- src/app/(protected)/pmschedules/index.tsx | 2 +- src/app/(protected)/settings/dropdowns.tsx | 6 +- .../(protected)/settings/task-templates.tsx | 2 +- src/app/(protected)/uplifts/index.tsx | 4 +- src/app/(protected)/vendor-pos/[id].tsx | 8 +- src/app/(protected)/vendor-pos/index.tsx | 2 +- .../_components/vendor-create-modal.tsx | 4 +- .../_components/vendor-detail-drawer.tsx | 12 +-- .../vendors/_components/vendor-form.tsx | 4 +- src/app/(protected)/vendors/index.tsx | 4 +- src/app/(protected)/workorders/[id].tsx | 2 +- .../_components/dispatch-create-modal.tsx | 4 +- .../_components/dispatch-detail-modal.tsx | 18 ++-- src/app/(protected)/workorders/index.tsx | 2 +- src/app/v/[token]/_layout.tsx | 6 +- src/app/v/[token]/dispatch/[id].tsx | 14 +-- .../common/calendar/event-calendar.tsx | 2 +- src/components/common/signature-capture.tsx | 2 +- src/components/ui/collapsible-section.tsx | 2 +- src/components/ui/empty-state.tsx | 12 +-- src/components/ui/filter-popover.tsx | 2 +- src/components/ui/form-field.tsx | 2 +- src/components/ui/page-header.tsx | 6 +- src/components/ui/text.tsx | 17 ++-- src/routing/app-shell.tsx | 24 ++--- src/test/components/ui/text.test.tsx | 44 ++++++++- 47 files changed, 308 insertions(+), 106 deletions(-) create mode 100644 eslint-rules/no-non-boolean-jsx-and.js diff --git a/docs/FRONTEND_MAINTAINABILITY.md b/docs/FRONTEND_MAINTAINABILITY.md index cd7863da..9b125ecc 100644 --- a/docs/FRONTEND_MAINTAINABILITY.md +++ b/docs/FRONTEND_MAINTAINABILITY.md @@ -18,6 +18,33 @@ state. Use a ternary only when both branches render meaningful alternatives. ESLint rejects `condition ? : null`. This keeps one-sided conditions visually distinct from real either-or UI decisions. +The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy +and non-boolean (notably `{count && }` renders `0`), so a type-aware ESLint rule, +`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the +TypeScript checker for the type of the left operand and reports unless every union constituent is +boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule +fails closed: if type services are unavailable in a governed TSX file it reports rather than +silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the +same constraint at that component boundary. + +Approved guard forms (choose by semantics, not as a blind codemod): + +- Presence-only values where falsy means "absent" — errors, optional strings shown only when set + (descriptions, notes, reasons), and optional objects (locations, detail records): coerce with + `Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`. + `{Boolean(error) && }` and `{Boolean(description) && }` are the canonical forms. +- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or + helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and + TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and + `{isEdit && id != null && }` keep `0`/`""` semantics and preserve + narrowing. +- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the + slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not + widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`. + +Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition +that needs a non-boolean operand must be rewritten into one of the approved forms above. + ## Typography and feedback Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, @@ -29,9 +56,31 @@ code, and asynchronous feedback. It owns: - accessible `alert` and `status` live regions for error and feedback text; - one-sided conditional text through `when`. +The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from +`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned +attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to +choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or +`aria-live` directly. + +Live-region behavior: + +- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so + the polite region exists before its content changes and is announced reliably. Mounting an + already-populated status node on demand is not announceable on most screen-reader/browser pairs. +- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the + expected error pattern, so the shipped `when={Boolean(error)}` usages are correct. + ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, but new shared UI should prefer `Text` so semantics and design tokens do not drift. +The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear +on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static +string literal containing `vp-error` (e.g. `
    `, `
    `); pair the error message with `variant="error"`. The rule enforces the +static surface only. It cannot resolve dynamic or composed class values +(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped +strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`. + ## Forms and mutations Use the libraries already established in the application: diff --git a/eslint-rules/no-non-boolean-jsx-and.js b/eslint-rules/no-non-boolean-jsx-and.js new file mode 100644 index 00000000..10408424 --- /dev/null +++ b/eslint-rules/no-non-boolean-jsx-and.js @@ -0,0 +1,95 @@ +import ts from "typescript"; + +/** + * Local type-aware rule: the left operand of `&&` in JSX must be entirely + * boolean. + * + * `{value && }` renders its left operand when it is falsy and + * non-boolean (notably `{count && }` renders `0`), so the left operand + * must be `boolean` (or boolean literals) across the whole union. The rule + * asks the TypeScript checker for the type of the left operand and reports + * unless every union constituent is boolean-like. + * + * Type-aware by design: a selector that cannot see operand types would give + * false confidence rather than enforcement. If type services are unexpectedly + * unavailable in a governed TSX file, the rule fails closed (reports) instead + * of silently claiming the expression is safe. + */ +const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral; + +function isBooleanLikeType(type) { + return (type.flags & booleanLikeFlags) !== 0; +} + +function isEntirelyBoolean(type) { + if (type.isUnion()) { + return type.types.every((constituent) => isBooleanLikeType(constituent)); + } + return isBooleanLikeType(type); +} + +const transparentExpressionTypes = new Set([ + "ChainExpression", + "ParenthesizedExpression", + "TSAsExpression", + "TSNonNullExpression", + "TSTypeAssertion", +]); + +function isInRenderedPosition(node) { + let current = node; + while (current.parent) { + const parent = current.parent; + if (parent.type === "JSXExpressionContainer") { + return parent.parent?.type !== "JSXAttribute"; + } + if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) { + current = parent; + continue; + } + return false; + } + return false; +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the left operand of `&&` in JSX to be entirely boolean", + }, + schema: [], + messages: { + nonBooleanLeft: + 'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.', + typeServicesUnavailable: + "Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.", + }, + }, + create(context) { + const services = context.sourceCode?.parserServices ?? context.parserServices; + + return { + LogicalExpression(node) { + if (node.operator !== "&&") { + return; + } + if (!isInRenderedPosition(node)) { + return; + } + + if (!services || services.program === null) { + context.report({ node, messageId: "typeServicesUnavailable" }); + return; + } + + const leftType = services.getTypeAtLocation(node.left); + if (!isEntirelyBoolean(leftType)) { + context.report({ node: node.left, messageId: "nonBooleanLeft" }); + } + }, + }; + }, +}; + +export default rule; diff --git a/eslint.config.js b/eslint.config.js index f88e2a81..55ef4f71 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,14 @@ import pluginReactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; import tseslint from "typescript-eslint"; +import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js"; + +const localRules = { + rules: { + "no-non-boolean-jsx-and": noNonBooleanJsxAnd, + }, +}; + const legacyIgnores = [ "src/pages/**", "src/app/store.js", @@ -54,6 +62,7 @@ export default tseslint.config( plugins: { "react-hooks": pluginReactHooks, "react-refresh": pluginReactRefresh, + seahaven: localRules, }, rules: { ...pluginReactHooks.configs.recommended.rules, @@ -62,6 +71,7 @@ export default tseslint.config( "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + "seahaven/no-non-boolean-jsx-and": "error", "no-restricted-syntax": [ "error", { @@ -78,8 +88,9 @@ export default tseslint.config( }, { selector: - "JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']", - message: 'Use Text variant="error" for accessible, consistent error feedback.', + "JSXOpeningElement:not([name.name='Text']) > JSXAttribute[name.name='className'][value.type='Literal'][value.value=/vp-error/]", + message: + 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.', }, ], }, diff --git a/package.json b/package.json index ee9bb861..170958c4 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "test:watch": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix --max-warnings=0", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky" diff --git a/src/app/(auth)/_components/login-form.tsx b/src/app/(auth)/_components/login-form.tsx index e06ba713..3e3d8456 100644 --- a/src/app/(auth)/_components/login-form.tsx +++ b/src/app/(auth)/_components/login-form.tsx @@ -75,7 +75,9 @@ export function LoginForm({ disabled={isLoggingIn} error={Boolean(errors.password)} /> - {loginError && } + {Boolean(loginError) && ( + + )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load accounts"} diff --git a/src/app/(protected)/assets/_components/asset-form.tsx b/src/app/(protected)/assets/_components/asset-form.tsx index d01ae284..513f1837 100644 --- a/src/app/(protected)/assets/_components/asset-form.tsx +++ b/src/app/(protected)/assets/_components/asset-form.tsx @@ -91,7 +91,7 @@ export default function AssetFormPage() { {isEdit ? "Edit Asset" : "Create Asset"} - {(error || optionsError) && ( + {Boolean(error || optionsError) && ( {error instanceof Error ? error.message diff --git a/src/app/(protected)/assets/index.tsx b/src/app/(protected)/assets/index.tsx index bea3dd35..e6d6451f 100644 --- a/src/app/(protected)/assets/index.tsx +++ b/src/app/(protected)/assets/index.tsx @@ -116,7 +116,7 @@ export default function AssetsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load assets"} diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx index 183201d2..01134222 100644 --- a/src/app/(protected)/calendar/_components/event-form.tsx +++ b/src/app/(protected)/calendar/_components/event-form.tsx @@ -120,7 +120,7 @@ export default function EventFormPage() { {isEdit ? "Edit Event" : "Create Event"} - {error && Failed to load event} + {Boolean(error) && Failed to load event} diff --git a/src/app/(protected)/contacts/_components/contact-form.tsx b/src/app/(protected)/contacts/_components/contact-form.tsx index f2fefc39..401265d6 100644 --- a/src/app/(protected)/contacts/_components/contact-form.tsx +++ b/src/app/(protected)/contacts/_components/contact-form.tsx @@ -127,7 +127,7 @@ export default function ContactFormPage() { {isEdit ? "Edit Contact" : "Create Contact"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contact"} diff --git a/src/app/(protected)/contacts/index.tsx b/src/app/(protected)/contacts/index.tsx index 54591c7e..e910c8f4 100644 --- a/src/app/(protected)/contacts/index.tsx +++ b/src/app/(protected)/contacts/index.tsx @@ -103,7 +103,7 @@ export default function ContactsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load contacts"} diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/dashboard.tsx +++ b/src/app/(protected)/dashboard.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/employees/_components/employee-form.tsx b/src/app/(protected)/employees/_components/employee-form.tsx index 78ed8bc1..6d5413a8 100644 --- a/src/app/(protected)/employees/_components/employee-form.tsx +++ b/src/app/(protected)/employees/_components/employee-form.tsx @@ -83,7 +83,7 @@ function SelectField({ ))} - {helperText && ( + {Boolean(helperText) && ( {helperText} @@ -193,7 +193,7 @@ export default function EmployeeFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employee"} diff --git a/src/app/(protected)/employees/index.tsx b/src/app/(protected)/employees/index.tsx index b570dbe3..ed56887b 100644 --- a/src/app/(protected)/employees/index.tsx +++ b/src/app/(protected)/employees/index.tsx @@ -124,7 +124,7 @@ export default function EmployeesListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load employees"} diff --git a/src/app/(protected)/followups/_components/follow-up-form.tsx b/src/app/(protected)/followups/_components/follow-up-form.tsx index 603dd3f1..d7a6a86c 100644 --- a/src/app/(protected)/followups/_components/follow-up-form.tsx +++ b/src/app/(protected)/followups/_components/follow-up-form.tsx @@ -102,7 +102,7 @@ export default function FollowUpFormPage() { {isEdit ? "Edit Follow-up" : "Create Follow-up"} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-up"} diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx index 69b42a59..30980fc0 100644 --- a/src/app/(protected)/followups/index.tsx +++ b/src/app/(protected)/followups/index.tsx @@ -129,7 +129,7 @@ export default function FollowUpsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-ups"} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx index 20be5454..e1af6bf9 100644 --- a/src/app/(protected)/index.tsx +++ b/src/app/(protected)/index.tsx @@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} )} - {description && ( + {Boolean(description) && ( {isFetching && !isLoading && } - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load dashboard stats"} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx index 79ef1acc..990fc75d 100644 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -114,7 +114,7 @@ export default function LocationFormPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load location"} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index df239574..097db2c8 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -118,7 +118,7 @@ export default function LocationsListPage() { - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load locations"} diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx index d3b82f15..0253a149 100644 --- a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx @@ -80,8 +80,8 @@ export default function PmScheduleFormPage() { {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} - {error && Failed to load PM schedule} - {optionsError && ( + {Boolean(error) && Failed to load PM schedule} + {Boolean(optionsError) && ( {optionsError instanceof Error ? optionsError.message : "Failed to load form options"} diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx index 4517d4ed..826381e7 100644 --- a/src/app/(protected)/pmschedules/index.tsx +++ b/src/app/(protected)/pmschedules/index.tsx @@ -122,7 +122,7 @@ export default function PmSchedulesListPage() { )} - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load PM schedules"} diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx index 4e2c0df7..e4aef562 100644 --- a/src/app/(protected)/settings/dropdowns.tsx +++ b/src/app/(protected)/settings/dropdowns.tsx @@ -158,7 +158,7 @@ export default function DropdownOptionsPage() { alignItems: "stretch", }} > - {parentCategory && ( + {Boolean(parentCategory) && ( Parent Trade - {error && ( + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load work orders"} diff --git a/src/app/v/[token]/_layout.tsx b/src/app/v/[token]/_layout.tsx index 3b4e7880..cd3bb545 100644 --- a/src/app/v/[token]/_layout.tsx +++ b/src/app/v/[token]/_layout.tsx @@ -10,7 +10,7 @@ function VendorPortalHeader() { return (
    Sea Haven Industries — Vendor Portal
    - {status === "ready" && vendor?.companyName && ( + {status === "ready" && vendor != null && Boolean(vendor.companyName) && (
    {vendor.companyName}
    )}
    @@ -56,13 +56,13 @@ function VendorPortalBody() { if (status === "error") { return (
    -
    + Access Denied {error?.message || "This link is invalid or has expired. Please contact your dispatcher for a new link."} -
    +
    ); } diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index 854631b9..a7000843 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -64,7 +64,7 @@ function ChecklistSection({ - {item.isCompleted && item.completedAt && ( + {item.isCompleted && Boolean(item.completedAt) && ( {formatVendorPortalDateTime(item.completedAt)} @@ -225,7 +225,7 @@ function UpliftRequestsSection({
    - {request.vendorReason && ( + {Boolean(request.vendorReason) && (
    Reason {request.vendorReason} @@ -469,13 +469,13 @@ export default function VendorPortalDispatchPage() {
    - {data.workOrder?.internalWONumber && ( + {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
    WO # {data.workOrder.internalWONumber}
    )} - {data.poNumber && ( + {Boolean(data.poNumber) && (
    PO # {data.poNumber} @@ -487,7 +487,7 @@ export default function VendorPortalDispatchPage() { ${Number(data.nteAmount).toFixed(2)}
    )} - {data.scheduledDate && ( + {Boolean(data.scheduledDate) && (
    Scheduled {formatVendorPortalDateTime(data.scheduledDate)} @@ -495,7 +495,7 @@ export default function VendorPortalDispatchPage() { )}
    - {data.location && ( + {data.location != null && ( <> Location @@ -514,7 +514,7 @@ export default function VendorPortalDispatchPage() { )} - {(data.description || data.workOrder?.description) && ( + {Boolean(data.description || data.workOrder?.description) && ( <> Description diff --git a/src/components/common/calendar/event-calendar.tsx b/src/components/common/calendar/event-calendar.tsx index ca7d11aa..0616c81b 100644 --- a/src/components/common/calendar/event-calendar.tsx +++ b/src/components/common/calendar/event-calendar.tsx @@ -22,7 +22,7 @@ export const EventCalendar = forwardRef(functi ) { return ( - {loading && ( + {Boolean(loading) && ( diff --git a/src/components/common/signature-capture.tsx b/src/components/common/signature-capture.tsx index c08123c2..9c14cf18 100644 --- a/src/components/common/signature-capture.tsx +++ b/src/components/common/signature-capture.tsx @@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro fullWidth size="small" /> - {typedName && ( + {Boolean(typedName) && (
    - {Icon && } + {Icon != null && } {label}
    diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 944591a7..5cf52b5f 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -1,13 +1,13 @@ -import type { ReactNode } from "react"; +import type { ReactElement } from "react"; import { Box, Typography } from "@mui/material"; import { cn } from "@/lib/utils"; type EmptyStateProps = { - icon?: ReactNode; + icon?: ReactElement; title: string; description?: string; - action?: ReactNode; + action?: ReactElement; className?: string; }; @@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt className, )} > - {icon && {icon}} + {Boolean(icon) && {icon}} {title} - {description && ( + {Boolean(description) && ( )} - {action && {action}} + {Boolean(action) && {action}} ); } diff --git a/src/components/ui/filter-popover.tsx b/src/components/ui/filter-popover.tsx index 59824344..2d8d5e8a 100644 --- a/src/components/ui/filter-popover.tsx +++ b/src/components/ui/filter-popover.tsx @@ -34,7 +34,7 @@ export function FilterPopover({
    Filters - {activeCount > 0 && onClear && ( + {activeCount > 0 && Boolean(onClear) && ( +
    + {activeCount > 0 && ( + + {activeCount} filter set + + )} +
    + +
    + + ); +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 66eecc87..5f28f6d2 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -3,6 +3,7 @@ import { Box } from "@mui/material"; import { useNavigate } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +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"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; @@ -44,6 +45,11 @@ export default function WorkOrdersListPage() { [problemOptions], ); + const vendorTechs = useMemo( + () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], + [vendors], + ); + const totalCount = tableData.totalCount; const scopeTotalCount = tableData.scopeTotalCount; @@ -62,6 +68,7 @@ export default function WorkOrdersListPage() { filters.setAdvOpen(true)} activeFilterCount={filters.activeFilterCount} isFetching={tableData.isFetching && !tableData.isLoading} /> @@ -104,6 +111,19 @@ export default function WorkOrdersListPage() { }} onOpenMedia={setMediaRow} onClearSearch={() => filters.handleSearchChange("")} + onOpenAdvancedFilters={() => filters.setAdvOpen(true)} + /> + + {activeMediaRow != null && ( 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 new file mode 100644 index 00000000..ab94481a --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -0,0 +1,154 @@ +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 { renderWithProviders } from "@/test/test-utils"; + +const setAdvOpen = vi.fn(); +const applyAdvancedFilters = vi.fn(); +const clearAdvancedFilters = vi.fn(); +let advOpen = false; +let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => vi.fn(), + }; +}); + +vi.mock("@/providers/auth-context", () => ({ + useAuthContext: () => ({ user: { id: "u1" } }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen, + setAdvOpen: (open: boolean) => { + advOpen = open; + setAdvOpen(open); + }, + advApplied, + advInitial: advApplied ?? EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: (filters: typeof EMPTY_ADVANCED_FILTERS) => { + advApplied = filters; + applyAdvancedFilters(filters); + }, + clearAdvancedFilters: () => { + advApplied = null; + clearAdvancedFilters(); + }, + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: advApplied ? 1 : 0, + weekMonday: "2026-07-20", + setWeekMonday: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + typeFilter: "All", + setTypeFilter: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ patchField: vi.fn() }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ + data: [{ id: "u1", name: "Dispatcher One" }], + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ + data: [{ id: "loc-1", name: "Seattle" }], + }), + useWorkOrderVendors: () => ({ + data: [{ companyName: "Vendor Co", email: "real@vendor.com" }], + }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }), +})); + +vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({ + isWoTableMockEnabled: () => false, +})); + +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, +})); + +describe("WorkOrders Advanced Search page wiring (SH-121)", () => { + beforeEach(() => { + advOpen = false; + advApplied = null; + setAdvOpen.mockClear(); + applyAdvancedFilters.mockClear(); + clearAdvancedFilters.mockClear(); + }); + + it("opens the sheet from Advanced Filters, applies, then clears", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + expect(screen.queryByRole("button", { name: /apply filters/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + expect(setAdvOpen).toHaveBeenCalledWith(true); + + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + expect(within(sheet).getByRole("button", { name: /apply filters/i })).toBeInTheDocument(); + + // Expand filter sections that start collapsed and assert option inputs are wired. + for (const label of ["Site", "Assigned to", "PM type", "Vendor / tech"]) { + fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") })); + } + expect(within(sheet).getByText("Seattle")).toBeInTheDocument(); + expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument(); + expect(within(sheet).getByText("HVAC")).toBeInTheDocument(); + expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument(); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + expect(applyAdvancedFilters).toHaveBeenCalled(); + expect(setAdvOpen).toHaveBeenCalledWith(false); + + advOpen = true; + view.rerender(); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Advanced Filters" })).getByRole("button", { + name: /clear all/i, + }), + ); + expect(clearAdvancedFilters).toHaveBeenCalled(); + }); +}); 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 effb2edc..1dcae791 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 @@ -25,10 +25,24 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = search: "", handleSearchChange: vi.fn(), setAdvOpen: vi.fn(), - activeFilterCount: 0, + advOpen: false, advApplied: null, - clearAdvancedFilterChip: vi.fn(), + advInitial: { + dateRange: "this-week", + customFrom: "", + customTo: "", + sites: new Set(), + types: new Set(), + dispatchers: new Set(), + statuses: new Set(), + pmTypes: new Set(), + vendorTechs: new Set(), + docs: new Set(), + }, + applyAdvancedFilters: vi.fn(), clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, weekMonday: "2026-07-20", setWeekMonday: vi.fn(), dispatcherIds: new Set(), @@ -165,13 +179,12 @@ describe("WorkOrdersListHeader slice affordances", () => { }); describe("WorkOrdersListPage board-core public interface", () => { - it("does not render New WO or Advanced Filters, and View navigates to detail", () => { + it("does not render New WO, and View navigates to detail", () => { navigate.mockReset(); renderWithProviders(, { route: "/workorders", withAuth: false }); expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Advanced Filters")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /view canceled row/i })); From 45909e7aa6a82c2aeac85f3b6fc69f2c9178304b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 18:10:45 -0300 Subject: [PATCH 10/10] Complete vendor operations roadmap frontend (#52) * feat(vendors): complete operations roadmap frontend * test(vendors): cover work order assignment flow * fix(vendors): address review feedback * fix(vendors): prevent stale preference saves * fix(vendors): require truthful appointment bounds --- e2e/vendors/vendor-operations.spec.ts | 508 ++++++++++++++ playwright.config.ts | 4 + scripts/governance-baseline.json | 5 - src/app/(protected)/notifications/index.tsx | 104 +++ src/app/(protected)/vendors/insights.tsx | 123 ++++ src/app/(protected)/vendors/preferences.tsx | 254 +++++++ .../_components/vendor-assignment-section.tsx | 153 ++++ .../_components/work-order-fields.tsx | 319 +++++++++ .../_components/work-order-form.tsx | 536 +++++++------- src/app/v/[token]/dispatch/[id].tsx | 657 ++---------------- .../_components/checklist-section.tsx | 70 ++ .../dispatch/_components/comments-section.tsx | 71 ++ .../_components/dispatch-detail-sections.tsx | 111 +++ .../_components/dispatch-status-actions.tsx | 54 ++ .../_components/dispatch-summary-card.tsx | 124 ++++ .../dispatch/_components/signoff-section.tsx | 135 ++++ .../_components/uplift-requests-section.tsx | 169 +++++ .../v/_components/vendor-portal-documents.tsx | 162 +++++ .../v/_components/vendor-portal-feedback.tsx | 19 + src/config/menu.ts | 6 + .../api/vendor-operations-api.ts | 81 +++ .../types/vendor-operations.ts | 79 +++ .../vendor-portal/api/vendor-portal-api.ts | 64 ++ .../vendor-portal/lib/document-validation.ts | 12 + .../vendor-portal/types/vendor-portal.ts | 20 + .../work-orders/mappers/work-order-mapper.ts | 27 +- .../mappers/work-order-vendor-mapper.ts | 60 ++ src/domain/work-orders/types/dispatch.ts | 8 + .../use-cases/use-save-work-order.ts | 83 +++ src/lib/time-utils.ts | 25 + .../app/v/vendor-portal-documents.test.tsx | 100 +++ 31 files changed, 3264 insertions(+), 879 deletions(-) create mode 100644 e2e/vendors/vendor-operations.spec.ts create mode 100644 src/app/(protected)/notifications/index.tsx create mode 100644 src/app/(protected)/vendors/insights.tsx create mode 100644 src/app/(protected)/vendors/preferences.tsx create mode 100644 src/app/(protected)/workorders/_components/vendor-assignment-section.tsx create mode 100644 src/app/(protected)/workorders/_components/work-order-fields.tsx create mode 100644 src/app/v/[token]/dispatch/_components/checklist-section.tsx create mode 100644 src/app/v/[token]/dispatch/_components/comments-section.tsx create mode 100644 src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx create mode 100644 src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx create mode 100644 src/app/v/[token]/dispatch/_components/dispatch-summary-card.tsx create mode 100644 src/app/v/[token]/dispatch/_components/signoff-section.tsx create mode 100644 src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx create mode 100644 src/app/v/_components/vendor-portal-documents.tsx create mode 100644 src/app/v/_components/vendor-portal-feedback.tsx create mode 100644 src/domain/vendor-operations/api/vendor-operations-api.ts create mode 100644 src/domain/vendor-operations/types/vendor-operations.ts create mode 100644 src/domain/vendor-portal/lib/document-validation.ts create mode 100644 src/domain/work-orders/mappers/work-order-vendor-mapper.ts create mode 100644 src/domain/work-orders/use-cases/use-save-work-order.ts create mode 100644 src/test/app/v/vendor-portal-documents.test.tsx diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts new file mode 100644 index 00000000..97d23975 --- /dev/null +++ b/e2e/vendors/vendor-operations.spec.ts @@ -0,0 +1,508 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "vendor-operations-e2e-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Vendor Operations Admin", + id: "1", +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function authenticate(page: Page) { + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); +} + +test.describe("Vendor operations", () => { + test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({ + page, + }) => { + await authenticate(page); + await page.route("**/api/vendor-operations/notifications", (route) => + fulfillJson(route, { + items: [ + { + id: "no-vendor-501", + type: "NoVendor", + severity: "Medium", + group: "No Vendor", + title: "No vendor assigned — 48h to service", + workOrderId: 501, + workOrderNumber: "WO-501", + triggeredAt: "2026-07-23T12:00:00Z", + scheduledAt: "2026-07-25T12:00:00Z", + action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" }, + }, + { + id: "vendor-conflict-801-802", + type: "VendorConflict", + severity: "Medium", + group: "Vendor Conflict", + title: "Vendor has overlapping appointments", + triggeredAt: "2026-07-23T12:00:00Z", + workOrders: [ + { + workOrderId: 501, + workOrderNumber: "WO-501", + start: "2026-07-25T12:00:00Z", + end: "2026-07-25T14:00:00Z", + }, + { + workOrderId: 502, + workOrderNumber: "WO-502", + start: "2026-07-25T13:00:00Z", + end: "2026-07-25T15:00:00Z", + }, + ], + actions: [ + { label: "WO-501", href: "/workorders/501" }, + { label: "WO-502", href: "/workorders/502" }, + ], + }, + ], + }), + ); + + await page.goto("/notifications"); + + await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute( + "href", + "/workorders/edit/501?assignVendor=1", + ); + await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute( + "href", + "/workorders/501", + ); + await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute( + "href", + "/workorders/502", + ); + }); + + test("filters insights and exposes both export formats", async ({ page }) => { + await authenticate(page); + const requestedUrls: string[] = []; + await page.route("**/api/vendor-operations/insights*", async (route) => { + requestedUrls.push(route.request().url()); + const { pathname } = new URL(route.request().url()); + if (pathname.endsWith(".csv")) { + await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" }); + return; + } + if (pathname.endsWith(".pdf")) { + await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" }); + return; + } + await fulfillJson(route, { + data: [ + { + vendorId: 7, + vendor: "Gateway Plumbing", + completedJobs: 12, + cancellationRefusalRate: 0.1, + onTimeArrivalRate: 0.9, + onTimeCompletionRate: 0.8, + averageCycleHours: 5.5, + }, + ], + }); + }); + + await page.goto("/vendors/insights"); + + await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible(); + await page.getByLabel("From", { exact: true }).fill("2026-01-01"); + await page.getByLabel("To", { exact: true }).fill("2026-01-31"); + await page.getByLabel("Trade", { exact: true }).fill("Plumbing"); + + const insightsQueryHas = (name: string, value: string) => + requestedUrls.some( + (url) => + new URL(url).pathname.endsWith("/insights") && + new URL(url).searchParams.get(name) === value, + ); + await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true); + await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true); + await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true); + + await page.getByRole("button", { name: "Export CSV" }).click(); + await page.getByRole("button", { name: "Export PDF" }).click(); + + const exportRequest = (format: "csv" | "pdf") => + requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`)); + const exportHasFilters = (url: string | undefined) => { + if (!url) return false; + const params = new URL(url).searchParams; + return ( + params.get("from") === "2026-01-01" && + params.get("to") === "2026-01-31" && + params.get("trade") === "Plumbing" + ); + }; + + await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true); + await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true); + }); + + test("reorders and saves preferred vendors for a site", async ({ page }) => { + await authenticate(page); + let savedPreferences: unknown; + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Vendor/Dropdown*", (route) => + fulfillJson(route, { + data: [ + { Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" }, + { Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" }, + ], + }), + ); + await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => { + if (route.request().method() === "PUT") { + savedPreferences = route.request().postDataJSON(); + await fulfillJson(route, savedPreferences); + return; + } + await fulfillJson(route, [ + { + id: 1, + locationId: 10, + vendorId: 7, + vendorName: "Gateway Plumbing", + trade: "Plumbing", + sortOrder: 0, + isStale: false, + }, + { + id: 2, + locationId: 10, + vendorId: 8, + vendorName: "Metro HVAC", + trade: "HVAC", + sortOrder: 1, + isStale: false, + }, + ]); + }); + + await page.goto("/vendors/preferences"); + await page.getByLabel("Site").click(); + await page.getByRole("option", { name: "St. Louis HQ" }).click(); + await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible(); + await page.getByRole("button", { name: "Move Metro HVAC up" }).click(); + await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible(); + await page.getByRole("button", { name: "Save preferences" }).click(); + + await expect + .poll(() => savedPreferences) + .toEqual([ + { vendorId: 8, trade: "HVAC", sortOrder: 0 }, + { vendorId: 7, trade: "Plumbing", sortOrder: 1 }, + ]); + }); + + test("creates a work order and assigns the selected technician with ETA", async ({ page }) => { + await authenticate(page); + let assignment: unknown; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/AddWorkorder", (route) => + fulfillJson(route, { + data: { + Id: 900, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => { + assignment = route.request().postDataJSON(); + await fulfillJson(route, { + availabilityStatus: "Available", + conflicts: [ + { + workOrderId: 502, + workOrderNumber: "WO-502", + start: "2026-07-25T12:00:00Z", + end: "2026-07-25T14:00:00Z", + }, + ], + }); + }); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 900, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/new"); + await page.getByLabel("Title").fill("Boiler repair"); + await page.getByLabel("Location").click(); + await page.getByRole("option", { name: "St. Louis HQ" }).click(); + await page.getByLabel("Priority").click(); + await page.getByRole("option", { name: "Sev 1" }).click(); + await page.getByLabel("Scheduled").fill("2026-07-25"); + await page.getByLabel("Appointment start").fill("2026-07-25T10:00"); + await page.getByLabel("Appointment end").fill("2026-07-25T12:00"); + await page.getByLabel("Company + Technician").click(); + await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click(); + await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30"); + await page.getByLabel("Manual ETA override").check(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect + .poll(() => assignment) + .toEqual({ + vendorId: 7, + scheduledStart: "2026-07-25T10:00:00.000Z", + scheduledEnd: "2026-07-25T12:00:00.000Z", + estimatedArrivalAt: "2026-07-25T08:30:00.000Z", + etaManualOverride: true, + }); + await expect(page.getByText(/scheduling conflict/i)).toBeVisible(); + await expect(page).toHaveURL(/\/workorders\/900$/); + }); + + test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => { + const context = await browser.newContext({ timezoneId: "America/New_York" }); + const page = await context.newPage(); + await authenticate(page); + let assignment: Record | undefined; + + const workOrder = { + Id: 901, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + CreatedDate: "2026-07-20", + ScheduledDate: "2026-07-25", + Comments: [], + AuditLog: [], + Dispatches: [ + { + Id: 44, + VendorId: 7, + Status: "Acknowledged", + EstimatedArrivalAt: "2026-07-25T12:30:00.000Z", + EtaManualOverride: true, + }, + ], + }; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { data: workOrder }), + ); + await page.route("**/api/WorkOrder/EditWorkorder", (route) => + fulfillJson(route, { data: workOrder }), + ); + await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => { + assignment = route.request().postDataJSON() as Record; + await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] }); + }); + + await page.goto("/workorders/edit/901"); + await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30"); + await page.getByRole("button", { name: "Update" }).click(); + + await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z"); + await context.close(); + }); + + test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({ + browser, + }) => { + // America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT. + // The window below straddles the transition: 01:00 is EST (UTC-5) while + // 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no + // fixed-window assumption remains. + const context = await browser.newContext({ timezoneId: "America/New_York" }); + const page = await context.newPage(); + await authenticate(page); + let assignment: Record | undefined; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/AddWorkorder", (route) => + fulfillJson(route, { + data: { + Id: 902, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => { + assignment = route.request().postDataJSON() as Record; + await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] }); + }); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 902, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/new"); + await page.getByLabel("Title").fill("Boiler repair"); + await page.getByLabel("Location").click(); + await page.getByRole("option", { name: "St. Louis HQ" }).click(); + await page.getByLabel("Priority").click(); + await page.getByRole("option", { name: "Sev 1" }).click(); + await page.getByLabel("Appointment start").fill("2026-03-08T01:00"); + await page.getByLabel("Appointment end").fill("2026-03-08T04:00"); + await page.getByLabel("Company + Technician").click(); + await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z"); + await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z"); + await context.close(); + }); + + test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({ + page, + }) => { + await authenticate(page); + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 501, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/edit/501?assignVendor=1"); + + const vendorSection = page.getByRole("region", { name: "Vendor & Time" }); + await expect(vendorSection).toBeVisible(); + await expect(vendorSection).toBeFocused(); + await page.getByLabel("Company + Technician").click(); + const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }); + await expect(option).toContainText("Unknown"); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 5a79ae6d..7360b05c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ use: { // Port 4173 isolates Playwright from the app's normal port 3000 server. baseURL: "http://localhost:4173", + // Pin the browser timezone to UTC so date-to-ISO assertions in the default + // create flow are deterministic regardless of host locale. DST-transition + // scenarios override this with their own non-UTC context. + timezoneId: "UTC", trace: "on-first-retry", }, projects: [ diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json index 197c24da..d82858fd 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -13,11 +13,6 @@ "maxLines": 782, "reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields." }, - { - "path": "src/app/v/[token]/dispatch/[id].tsx", - "maxLines": 651, - "reason": "Vendor-portal dispatch detail route; legacy page pending decomposition." - }, { "path": "src/app/(protected)/vendors/index.tsx", "maxLines": 612, diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx new file mode 100644 index 00000000..c4e0b75e --- /dev/null +++ b/src/app/(protected)/notifications/index.tsx @@ -0,0 +1,104 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Paper, + Stack, + Typography, +} from "@mui/material"; +import { Link } from "react-router"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; + +export default function NotificationsPage() { + const { + data = [], + isLoading, + error, + } = useQuery({ + queryKey: ["vendor-operations", "notifications"], + queryFn: vendorOperationsApi.notifications, + refetchInterval: 30_000, + }); + + const groups = data.reduce((result, item) => { + const items = result.get(item.group) ?? []; + items.push(item); + result.set(item.group, items); + return result; + }, new Map()); + + return ( + + + + Items requiring attention + + + Medium-severity vendor reminders refresh automatically. They do not create banners or + toasts. + + + {isLoading && } + {Boolean(error) && {error?.message}} + {!isLoading && !error && data.length === 0 && ( + + No vendor alerts require attention. + + )} + {[...groups.entries()].map(([group, items]) => ( + + + {group} + + + + {items.map((item) => ( + + + + + + {item.title} + + + {item.workOrderNumber ?? + item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")} + + {item.scheduledAt != null && ( + + Scheduled {new Date(item.scheduledAt).toLocaleString()} + + )} + + + {item.action != null && ( + + )} + {item.actions?.map((action) => ( + + ))} + + + + ))} + + + ))} + + ); +} diff --git a/src/app/(protected)/vendors/insights.tsx b/src/app/(protected)/vendors/insights.tsx new file mode 100644 index 00000000..de5c5f0c --- /dev/null +++ b/src/app/(protected)/vendors/insights.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + CircularProgress, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; + +function percent(value: number) { + return `${(Number(value || 0) * 100).toFixed(1)}%`; +} + +export default function VendorInsightsPage() { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [trade, setTrade] = useState(""); + const params = { from: from || undefined, to: to || undefined, trade: trade || undefined }; + const { + data = [], + isLoading, + error, + } = useQuery({ + queryKey: ["vendor-operations", "insights", params], + queryFn: () => vendorOperationsApi.insights(params), + }); + + return ( + + + + Vendor performance insights + + + Metrics include inactive vendors for historical reporting and use the same filtered + dispatch population. + + + + + setFrom(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTo(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTrade(event.target.value)} + /> + + + + + {isLoading && } + {Boolean(error) && {error?.message}} + {!isLoading && !error && data.length === 0 && ( + No data matches the selected filters. + )} + {data.length > 0 && ( + + + + + Vendor + Completed + Cancellation / refusal + On-time arrival + On-time completion + Avg. cycle + + + + {data.map((row) => ( + + {row.vendor} + {row.completedJobs} + {percent(row.cancellationRefusalRate)} + {percent(row.onTimeArrivalRate)} + {percent(row.onTimeCompletionRate)} + + {row.averageCycleHours == null + ? "Insufficient data" + : `${Number(row.averageCycleHours).toFixed(1)}h`} + + + ))} + +
    +
    + )} +
    + ); +} diff --git a/src/app/(protected)/vendors/preferences.tsx b/src/app/(protected)/vendors/preferences.tsx new file mode 100644 index 00000000..fdb93618 --- /dev/null +++ b/src/app/(protected)/vendors/preferences.tsx @@ -0,0 +1,254 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Autocomplete, + Box, + Button, + IconButton, + Paper, + Stack, + TextField, +} from "@mui/material"; +import { ArrowDown, ArrowUp, Trash2 } from "lucide-react"; +import { toast } from "react-toastify"; +import { Text } from "@/components/ui/text"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; +import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations"; +import { + useWorkOrderLocations, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; + +const EMPTY_PREFERENCES: SiteVendorPreference[] = []; + +type AddVendorPreferenceControlsProps = { + trade: string; + onTradeChange: (value: string) => void; + vendorOptions: VendorDropdownItem[]; + vendors: VendorDropdownItem[]; + vendorId: number | null; + onVendorChange: (vendorId: number | null) => void; + onAdd: () => void; +}; + +function AddVendorPreferenceControls({ + trade, + onTradeChange, + vendorOptions, + vendors, + vendorId, + onVendorChange, + onAdd, +}: AddVendorPreferenceControlsProps) { + return ( + + + onTradeChange(event.target.value)} + /> + + `${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}` + } + value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null} + onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)} + renderInput={(params) => } + className="flex-1" + /> + + + + ); +} + +type PreferenceRowProps = { + item: SiteVendorPreference; + index: number; + total: number; + onMove: (index: number, offset: number) => void; + onRemove: (index: number) => void; +}; + +function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) { + return ( + + + + {index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"} + + {item.isStale && ( + + Stale preference + + )} + onMove(index, -1)} + disabled={index === 0} + > + + + onMove(index, 1)} + disabled={index === total - 1} + > + + + onRemove(index)}> + + + + + ); +} + +export default function VendorPreferencesPage() { + const queryClient = useQueryClient(); + const [locationId, setLocationId] = useState(""); + const [draft, setDraft] = useState([]); + const [isDirty, setIsDirty] = useState(false); + const [trade, setTrade] = useState(""); + const [vendorId, setVendorId] = useState(null); + const { data: locations = [] } = useWorkOrderLocations(); + const { data: vendors = [] } = useWorkOrderVendors(trade || undefined); + const queryKey = ["vendor-operations", "site-preferences", locationId]; + const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({ + queryKey, + queryFn: () => vendorOperationsApi.sitePreferences(locationId), + enabled: locationId !== "", + }); + + // A background refetch must not clobber in-flight edits, so only adopt server + // data while the draft is clean (initial load, site change, or after a save). + useEffect(() => { + if (!isDirty) { + setDraft(serverPreferences); + } + }, [isDirty, serverPreferences]); + + const vendorOptions = useMemo(() => { + const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId)); + return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id))); + }, [draft, vendors]); + + const save = useMutation({ + mutationFn: () => + vendorOperationsApi.replaceSitePreferences( + locationId, + draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })), + ), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey }); + }, + onSuccess: (savedPreferences) => { + queryClient.setQueryData(queryKey, savedPreferences); + setDraft(savedPreferences); + setIsDirty(false); + toast.success("Preferred vendors updated"); + }, + onError: () => toast.error("Unable to save preferred vendors. Please try again."), + }); + + const move = (index: number, offset: number) => { + setDraft((current) => { + const target = index + offset; + if (target < 0 || target >= current.length) return current; + const next = [...current]; + [next[index], next[target]] = [next[target]!, next[index]!]; + return next.map((item, sortOrder) => ({ ...item, sortOrder })); + }); + setIsDirty(true); + }; + + const add = () => { + const vendor = vendors.find((item) => Number(item.id) === vendorId); + if (!vendor || locationId === "") return; + setDraft((current) => [ + ...current, + { + id: -Date.now(), + locationId: Number(locationId), + vendorId: Number(vendor.id), + vendorName: vendor.companyName, + trade: trade.trim() || undefined, + sortOrder: current.length, + isStale: false, + }, + ]); + setVendorId(null); + setIsDirty(true); + }; + + const remove = (index: number) => { + setDraft((current) => current.filter((_, row) => row !== index)); + setIsDirty(true); + }; + + const selectLocation = (nextLocationId: string | number) => { + setLocationId(nextLocationId); + setDraft(EMPTY_PREFERENCES); + setIsDirty(false); + }; + + return ( + + + + Preferred vendors by site + + + Preferred vendors rank first, but availability, conflict, trade, active-state, and + permission checks still apply. + + + + option.name} + value={locations.find((location) => location.id === locationId) ?? null} + onChange={(_event, option) => selectLocation(option?.id ?? "")} + renderInput={(params) => } + /> + + + Unable to load preferred vendors. Please try again. + + {locationId !== "" && ( + <> + + + {draft.map((item, index) => ( + + ))} + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx b/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx new file mode 100644 index 00000000..92e55413 --- /dev/null +++ b/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx @@ -0,0 +1,153 @@ +import { + Alert, + Autocomplete, + Box, + Chip, + FormControlLabel, + Stack, + Switch, + TextField, +} from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { VendorAvailability } from "@/domain/vendor-operations/types/vendor-operations"; + +function SectionTitle({ children }: { children: string }) { + return ( + + {children} + + ); +} + +type VendorAndTimeSectionProps = { + containerRef: React.RefObject; + availableVendors: VendorAvailability[]; + isLoadingVendors: boolean; + selectedVendorId: number | null; + onSelectVendor: (id: number | null) => void; + estimatedArrivalAt: string; + onEtaChange: (value: string) => void; + etaManualOverride: boolean; + onOverrideChange: (value: boolean) => void; + scheduledStartLocal: string; + onScheduledStartChange: (value: string) => void; + scheduledEndLocal: string; + onScheduledEndChange: (value: string) => void; + scheduleBoundsError: string | null; +}; + +export function VendorAndTimeSection({ + containerRef, + availableVendors, + isLoadingVendors, + selectedVendorId, + onSelectVendor, + estimatedArrivalAt, + onEtaChange, + etaManualOverride, + onOverrideChange, + scheduledStartLocal, + onScheduledStartChange, + scheduledEndLocal, + onScheduledEndChange, + scheduleBoundsError, +}: VendorAndTimeSectionProps) { + const hasBoundsError = Boolean(scheduleBoundsError); + return ( + + Vendor & Time + + onScheduledStartChange(event.target.value)} + error={hasBoundsError} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + onScheduledEndChange(event.target.value)} + error={hasBoundsError} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + + + {scheduleBoundsError} + + + + `${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}` + } + value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null} + onChange={(_event, option) => onSelectVendor(option?.id ?? null)} + renderOption={(props, option) => ( +
  • + + + + {option.companyName} + {option.contactName ? ` · ${option.contactName}` : ""} + + {option.isPreferred && } + + + {option.conflicts.length > 0 && ( + + Conflicts with{" "} + {option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")} + + )} + +
  • + )} + renderInput={(params) => } + className="flex-1" + /> + onEtaChange(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + onOverrideChange(event.target.checked)} + /> + } + label="Manual ETA override" + /> +
    + {selectedVendorId != null && + availableVendors.find((vendor) => vendor.id === selectedVendorId)?.availabilityStatus === + "Unknown" && ( + + + Availability is unknown. This selection is not treated as confirmed availability. + + + )} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-fields.tsx b/src/app/(protected)/workorders/_components/work-order-fields.tsx new file mode 100644 index 00000000..501d69c2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/work-order-fields.tsx @@ -0,0 +1,319 @@ +import { Autocomplete, Box, MenuItem, Stack, TextField } from "@mui/material"; +import { Controller } from "react-hook-form"; +import type { Control, FieldErrors, UseFormRegister } from "react-hook-form"; +import { Text } from "@/components/ui/text"; +import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option"; +import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema"; +import { + WORK_ORDER_PRIORITY_OPTIONS, + WORK_ORDER_STATUS_OPTIONS, + type LocationOption, +} from "@/domain/work-orders/types/work-order"; +import type { EmployeeOption } from "@/domain/work-orders/types/work-order-common"; + +function SectionTitle({ children }: { children: string }) { + return ( + + {children} + + ); +} + +type FormFieldHandlers = { + register: UseFormRegister; + control: Control; + errors: FieldErrors; +}; + +type WorkOrderDetailsFieldsProps = FormFieldHandlers & { + locationOptions: LocationOption[]; + isLoadingLocations: boolean; +}; + +export function WorkOrderDetailsFields({ + register, + control, + errors, + locationOptions, + isLoadingLocations, +}: WorkOrderDetailsFieldsProps) { + return ( + + Work Order Details + + + + + + ( + option.name} + value={ + locationOptions.find((location) => String(location.id) === field.value) ?? null + } + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + ); +} + +type ClassificationFieldsProps = { + control: Control; + problems: DropdownOption[]; + trades: DropdownOption[]; + subTrades: DropdownOption[]; + tradeValue: string; +}; + +export function ClassificationFields({ + control, + problems, + trades, + subTrades, + tradeValue, +}: ClassificationFieldsProps) { + return ( + + Classification + + ( + + Select Problem + {problems.map((option) => ( + + {option.value} + + ))} + + )} + /> + ( + + Select Trade + {trades.map((option) => ( + + {option.value} + + ))} + + )} + /> + ( + + + {tradeValue ? "Select Sub-Trade" : "Select a Trade first"} + + {subTrades.map((option) => ( + + {option.value} + + ))} + + )} + /> + + + ); +} + +type ScheduleFieldsProps = FormFieldHandlers & { + employees: EmployeeOption[]; +}; + +export function ScheduleFields({ register, control, errors, employees }: ScheduleFieldsProps) { + return ( + + Schedule & Priority + + ( + + Select Priority + {WORK_ORDER_PRIORITY_OPTIONS.map((priority) => ( + + {priority} + + ))} + + )} + /> + ( + + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + + {status} + + ))} + + )} + /> + ( + employee.name)} + value={field.value} + onChange={(_event, value) => field.onChange(value ?? "")} + onInputChange={(_event, value) => field.onChange(value)} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + + + + + ); +} + +export function FinancialFields({ + register, +}: { + register: UseFormRegister; +}) { + return ( + + Financial + + + + + + + ); +} + +export function DescriptionFields({ + register, +}: { + register: UseFormRegister; +}) { + return ( + + Description + + + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx index 69b1bedd..35e3afa7 100644 --- a/src/app/(protected)/workorders/_components/work-order-form.tsx +++ b/src/app/(protected)/workorders/_components/work-order-form.tsx @@ -1,62 +1,141 @@ -import { useEffect, useMemo } from "react"; -import { Controller, useForm, useWatch } from "react-hook-form"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Link as RouterLink, useNavigate, useParams } from "react-router"; -import { - Alert, - Autocomplete, - Box, - Breadcrumbs, - Button, - CircularProgress, - Link, - MenuItem, - Paper, - Stack, - TextField, - Typography, -} from "@mui/material"; +import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router"; +import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper"; import { workOrderFormSchema, type WorkOrderFormSchemaValues, } from "@/domain/work-orders/schemas/work-order-schema"; -import { - createDefaultWorkOrderFormValues, - WORK_ORDER_PRIORITY_OPTIONS, - WORK_ORDER_STATUS_OPTIONS, - type LocationOption, -} from "@/domain/work-orders/types/work-order"; +import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order"; import { useWorkOrderDetail, useWorkOrderEmployeesDropdown, useWorkOrderLocations, } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useSaveWorkOrder } from "@/domain/work-orders/use-cases/use-save-work-order"; +import { toDateTimeLocalInputValue } from "@/lib/time-utils"; import { - useCreateWorkOrder, - useUpdateWorkOrder, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; + ClassificationFields, + DescriptionFields, + FinancialFields, + ScheduleFields, + WorkOrderDetailsFields, +} from "@/app/(protected)/workorders/_components/work-order-fields"; +import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section"; -function SectionTitle({ children }: { children: string }) { - return ( - - {children} - - ); +type ScheduleBounds = { + kind: "valid" | "invalid" | "open"; + startIso: string | undefined; + endIso: string | undefined; + error: string | null; +}; + +function computeScheduleBounds(startLocal: string, endLocal: string): ScheduleBounds { + const hasStart = startLocal.trim() !== ""; + const hasEnd = endLocal.trim() !== ""; + + if (hasStart && hasEnd) { + const start = new Date(startLocal); + const end = new Date(endLocal); + if ( + !Number.isNaN(start.getTime()) && + !Number.isNaN(end.getTime()) && + end.getTime() > start.getTime() + ) { + return { + kind: "valid", + startIso: start.toISOString(), + endIso: end.toISOString(), + error: null, + }; + } + return { + kind: "invalid", + startIso: undefined, + endIso: undefined, + error: "Schedule end must be later than schedule start.", + }; + } + + if (hasStart || hasEnd) { + return { + kind: "invalid", + startIso: undefined, + endIso: undefined, + error: "Provide both a schedule start and end, or leave both blank.", + }; + } + + return { kind: "open", startIso: undefined, endIso: undefined, error: null }; } -export default function WorkOrderFormPage() { +function useVendorSchedule( + scheduledStartLocal: string, + scheduledEndLocal: string, + locationValue: string, + tradeValue: string, +) { + const scheduleBounds = useMemo( + () => computeScheduleBounds(scheduledStartLocal, scheduledEndLocal), + [scheduledStartLocal, scheduledEndLocal], + ); + + const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({ + queryKey: [ + "vendor-operations", + "availability", + scheduleBounds.startIso ?? null, + scheduleBounds.endIso ?? null, + locationValue, + tradeValue, + ], + queryFn: () => + vendorOperationsApi.availability({ + start: scheduleBounds.startIso, + end: scheduleBounds.endIso, + locationId: locationValue || undefined, + trade: tradeValue || undefined, + }), + enabled: scheduleBounds.kind !== "invalid", + staleTime: 30_000, + }); + + const truthfulVendors = + scheduleBounds.kind === "valid" + ? availableVendors + : availableVendors.map((vendor) => ({ + ...vendor, + availabilityStatus: "Unknown" as const, + conflicts: [], + })); + + return { availableVendors: truthfulVendors, isLoadingVendors, scheduleBounds }; +} + +function useWorkOrderFormState() { const { id } = useParams(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const isEdit = Boolean(id); + const assignVendorRequested = searchParams.get("assignVendor") === "1"; const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id); const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations(); const { data: employees = [] } = useWorkOrderEmployeesDropdown(); const { data: problems = [] } = useDropdownOptionsByCategory("Problem"); - const createMutation = useCreateWorkOrder(); - const updateMutation = useUpdateWorkOrder(id ?? ""); + const saveMutation = useSaveWorkOrder(); + const vendorSectionRef = useRef(null); + const [selectedVendorId, setSelectedVendorId] = useState(null); + const [estimatedArrivalAt, setEstimatedArrivalAt] = useState(""); + const [etaManualOverride, setEtaManualOverride] = useState(false); + const [scheduledStartLocal, setScheduledStartLocal] = useState(""); + const [scheduledEndLocal, setScheduledEndLocal] = useState(""); const { register, @@ -71,6 +150,14 @@ export default function WorkOrderFormPage() { }); const tradeValue = useWatch({ control, name: "trade" }); + const locationValue = useWatch({ control, name: "locationId" }); + + const { availableVendors, isLoadingVendors, scheduleBounds } = useVendorSchedule( + scheduledStartLocal, + scheduledEndLocal, + locationValue, + tradeValue, + ); const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", { parent: tradeValue || undefined, @@ -81,6 +168,10 @@ export default function WorkOrderFormPage() { useEffect(() => { if (isEdit && detail) { reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues); + const dispatch = detail.dispatches.find((item) => item.status !== "Cancelled"); + setSelectedVendorId(dispatch?.vendorId ? Number(dispatch.vendorId) : null); + setEstimatedArrivalAt(toDateTimeLocalInputValue(dispatch?.estimatedArrivalAt)); + setEtaManualOverride(Boolean(dispatch?.etaManualOverride)); } }, [detail, isEdit, reset]); @@ -90,23 +181,120 @@ export default function WorkOrderFormPage() { } }, [tradeValue, setValue]); - const locationOptions = useMemo(() => locations, [locations]); - - const isSaving = createMutation.isPending || updateMutation.isPending; const isLoading = isEdit && isLoadingDetail; + useEffect(() => { + if (assignVendorRequested && !isLoading) { + const node = vendorSectionRef.current; + if (node) { + node.scrollIntoView({ behavior: "smooth", block: "start" }); + node.focus(); + } + } + }, [assignVendorRequested, isLoading]); + const onSubmit = (values: WorkOrderFormSchemaValues) => { - if (isEdit && id) { - updateMutation.mutate(values); + if (scheduleBounds.kind === "invalid") { return; } - createMutation.mutate(values); + saveMutation.mutate({ + workOrderId: isEdit ? id : undefined, + values, + vendorAssignment: + selectedVendorId == null + ? undefined + : { + vendorId: selectedVendorId, + scheduledStart: scheduleBounds.startIso, + scheduledEnd: scheduleBounds.endIso, + estimatedArrivalAt: estimatedArrivalAt + ? new Date(estimatedArrivalAt).toISOString() + : undefined, + etaManualOverride, + }, + }); }; const handleCancel = () => { navigate(isEdit && id ? `/workorders/${id}` : "/workorders"); }; + return { + id, + isEdit, + detail, + isLoading, + detailError, + isSaving: saveMutation.isPending, + register, + control, + handleSubmit, + errors, + locationOptions: locations, + isLoadingLocations, + employees, + problems, + trades, + subTrades, + tradeValue, + vendorSectionRef, + availableVendors, + isLoadingVendors, + selectedVendorId, + setSelectedVendorId, + estimatedArrivalAt, + setEstimatedArrivalAt, + etaManualOverride, + setEtaManualOverride, + scheduledStartLocal, + setScheduledStartLocal, + scheduledEndLocal, + setScheduledEndLocal, + scheduleBoundsError: scheduleBounds.error, + isScheduleBoundsInvalid: scheduleBounds.kind === "invalid", + onSubmit, + handleCancel, + }; +} + +export default function WorkOrderFormPage() { + const { + isEdit, + id, + detail, + isLoading, + detailError, + isSaving, + register, + control, + handleSubmit, + errors, + locationOptions, + isLoadingLocations, + employees, + problems, + trades, + subTrades, + tradeValue, + vendorSectionRef, + availableVendors, + isLoadingVendors, + selectedVendorId, + setSelectedVendorId, + estimatedArrivalAt, + setEstimatedArrivalAt, + etaManualOverride, + setEtaManualOverride, + scheduledStartLocal, + setScheduledStartLocal, + scheduledEndLocal, + setScheduledEndLocal, + scheduleBoundsError, + isScheduleBoundsInvalid, + onSubmit, + handleCancel, + } = useWorkOrderFormState(); + if (isLoading) { return ( @@ -118,9 +306,7 @@ export default function WorkOrderFormPage() { if (detailError) { return ( - - {detailError instanceof Error ? detailError.message : "Failed to load work order"} - + Unable to load this work order. Please try again. ); } @@ -131,232 +317,66 @@ export default function WorkOrderFormPage() { Work Orders - - {isEdit ? "Edit" : "Create"} - + {isEdit ? "Edit" : "Create"} - + {isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"} - + - - Work Order Details - - - - - - ( - option.name} - value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null} - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - + - - Classification - - - Select Problem - {problems.map((option) => ( - - {option.value} - - ))} - - - Select Trade - {trades.map((option) => ( - - {option.value} - - ))} - - - - {tradeValue ? "Select Sub-Trade" : "Select a Trade first"} - - {subTrades.map((option) => ( - - {option.value} - - ))} - - - + - - Schedule & Priority - - - Select Priority - {WORK_ORDER_PRIORITY_OPTIONS.map((priority) => ( - - {priority} - - ))} - - - {WORK_ORDER_STATUS_OPTIONS.map((status) => ( - - {status} - - ))} - - ( - employee.name)} - value={field.value} - onChange={(_event, value) => field.onChange(value ?? "")} - onInputChange={(_event, value) => field.onChange(value)} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - - - - + - - Financial - - - - - - + - - Description - - + - + + + - diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index a7000843..c09d9b4b 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -1,405 +1,21 @@ import { useCallback, useState } from "react"; -import { Link, useParams } from "react-router"; +import { useParams } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { SignaturePad } from "@/app/v/_components/signature-pad"; -import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { Text } from "@/components/ui/text"; +import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections"; +import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card"; +import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; -import { - formatVendorPortalDateTime, - vendorPortalStatusClass, -} from "@/domain/vendor-portal/lib/status-helpers"; -import type { - VendorPortalChecklistItem, - VendorPortalComment, - VendorPortalDispatchDetail, - VendorPortalSignoff, - VendorPortalUpliftRequest, -} from "@/domain/vendor-portal/types/vendor-portal"; +import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; import { queryKeys } from "@/infra/query-key/query-key"; -function ChecklistSection({ - token, - dispatchId, - items, - locked, - onItemUpdated, -}: { - token: string; - dispatchId: number; - items: VendorPortalChecklistItem[]; - locked: boolean; - onItemUpdated: (item: VendorPortalChecklistItem) => void; -}) { - const toggle = async (item: VendorPortalChecklistItem) => { - if (locked) return; - try { - const updated = await vendorPortalApi.updateChecklistItem( - token, - dispatchId, - item.id, - !item.isCompleted, - ); - onItemUpdated(updated); - } catch (error) { - alert(error instanceof Error ? error.message : "Failed to update checklist"); - } - }; - - if (!items.length) { - return
    No checklist items for this dispatch.
    ; - } - - return ( -
    - {items.map((item) => ( -
    - void toggle(item)} - disabled={locked} - /> - - {item.isCompleted && Boolean(item.completedAt) && ( - - {formatVendorPortalDateTime(item.completedAt)} - - )} -
    - ))} -
    - ); -} - -function CommentsSection({ - token, - dispatchId, - comments, - onAdded, -}: { - token: string; - dispatchId: number; - comments: VendorPortalComment[]; - onAdded: (comment: VendorPortalComment) => void; -}) { - const [text, setText] = useState(""); - const [submitting, setSubmitting] = useState(false); - - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!text.trim()) return; - setSubmitting(true); - try { - const added = await vendorPortalApi.addComment(token, dispatchId, text.trim()); - onAdded(added); - setText(""); - } catch (error) { - alert(error instanceof Error ? error.message : "Failed to post comment"); - } finally { - setSubmitting(false); - } - }; - - return ( - <> - {comments.length === 0 &&
    No comments yet.
    } - {comments.map((comment) => ( -
    -
    - {comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)} -
    -
    {comment.commenttext}
    -
    - ))} -
    void submit(event)} style={{ marginTop: 12 }}> -