From 20237f8142f948eab63f2b2034dd42114dd8cc24 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 15:23:03 -0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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) && (