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(); + }); +});