diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index fb46f610..e5c8bcf3 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,10 +306,17 @@ test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); - await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); + const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("font-family", /Montserrat/); + await expect(heading).toHaveCSS("font-size", "30px"); + await expect(heading).toHaveCSS("font-weight", "800"); + await expect(heading).toHaveCSS("line-height", "33px"); + await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", @@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", - "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); + const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); + expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); + const tableContainerBox = await page.locator("table").locator("..").boundingBox(); + expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); + expect(tableContainerBox?.height).toBeCloseTo(738, 0); + expect((await page.locator("table").boundingBox())?.width).toBe(1550); + expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( + 47, + ); + expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active technicians")).toBeVisible(); + await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); - await page.getByPlaceholder("Search name, company, trade...").fill("metro"); + await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); - await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); + await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await page.getByPlaceholder("Search name, company, trade...").clear(); + await page.getByPlaceholder("Search name, company, trade…").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); - await page.getByRole("button", { name: "Apply" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText("1 technician · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); - await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( - "history will be preserved", - ); - await page.getByRole("button", { name: "Deactivate", exact: true }).click(); - await expect.poll(() => state.deletedId).toBe("1"); - await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); + await expect(page.getByText("No inactive technicians found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "+ Add Vendor" }).click(); - await expect( - page.getByText("Register a company and add zero, one, or multiple technicians."), - ).toBeVisible(); + await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); + await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); + await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( + "https://maps.google.com/gateway", + ); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician Name").last().fill("New Technician"); + await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); - await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); + await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByLabel("Notes").fill("Updated in browser E2E"); - await page.getByRole("button", { name: "Save" }).click(); + await expect(page).toHaveURL(/\/vendors$/); + await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); - await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByRole("switch", { name: "Inactive" }).first().click(); - await page.getByRole("button", { name: "Save" }).click(); + await detailDrawer.getByRole("switch", { name: "Active status" }).click(); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); + await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "+ Add Vendor" }); + const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await page.getByLabel("Company Phone (optional)").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); @@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -541,6 +560,7 @@ test.describe("Vendor directory prototype parity", () => { /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, ); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png"); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index 9b6f3b0d..b63291be 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,7 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(): VendorDeactivationState { +export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -39,7 +39,10 @@ export function useVendorDeactivation(): VendorDeactivationState { if (!target || target.id == null) return; setError(null); deleteVendor.mutate(target.id, { - onSuccess: () => setTarget(null), + onSuccess: () => { + setTarget(null); + onSuccess?.(); + }, onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); }; diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index 9385a834..fdc90685 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); + const [pageSize, setPageSize] = useState(48); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index f6ea637a..6bf89b1f 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, + emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -20,11 +21,30 @@ import type { VendorRosterConflict, } from "@/domain/vendors/types/vendor"; +function withoutBlankNewTechnicians( + values: VendorCompanyRosterFormValues, +): VendorCompanyRosterFormValues { + return { + ...values, + technicians: values.technicians.filter( + (technician) => + technician.id != null || + Boolean( + technician.contactName.trim() || + technician.phone.trim() || + technician.email.trim() || + technician.tradeSpecialties.trim(), + ), + ), + }; +} + export interface VendorRosterFormProps { mode: "create" | "update"; vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; + startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -55,6 +75,7 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, + startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -69,9 +90,16 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); + const createDefaults = useMemo( + () => ({ + ...emptyVendorCompanyRosterForm, + technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], + }), + [startWithTechnician], + ); const form = useForm({ resolver, - defaultValues: emptyVendorCompanyRosterForm, + defaultValues: createDefaults, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -96,10 +124,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(emptyVendorCompanyRosterForm); + reset(createDefaults); resetSelection(); clearConflict(); - }, [clearConflict, reset, resetSelection]); + }, [clearConflict, createDefaults, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; @@ -109,7 +137,7 @@ export function useVendorRosterForm({ save.mutate( { mode: isUpdate ? "update" : "create", - values: formValues, + values: withoutBlankNewTechnicians(formValues), companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index d0025662..31804a3c 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,7 +21,11 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const form = useVendorRosterForm({ + mode: "create", + onSuccess: onClose, + startWithTechnician: true, + }); const { resetForm } = form; useEffect(() => { @@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="md" + maxWidth="sm" aria-labelledby="vendor-create-dialog-title" - slotProps={{ paper: { role: "dialog" } }} + slotProps={{ + paper: { + role: "dialog", + sx: { maxHeight: "88vh", borderRadius: 2 }, + }, + }} > - Add Vendor Company - - Register a company and add zero, one, or multiple technicians. - + + Add Vendor + + + Register a vendor company and its technicians. + - + {form.loadErrorMessage !== null && ( @@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving || !form.isValid} + disabled={form.isSaving} > - {form.isSaving ? : "Add Vendor Company"} + {form.isSaving ? : "Add Vendor"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index ad7ffa29..74d6ef02 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useEffect, useState, type ReactNode } from "react"; +import { Controller, useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,15 +13,22 @@ import { Drawer, IconButton, Link, - List, - ListItem, Stack, - Typography, + Switch, } from "@mui/material"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; +import { Text } from "@/components/ui/text"; +import type { + VendorCompanyRoster, + VendorListItem, + VendorRosterTechnician, +} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 440; +const DRAWER_WIDTH = 420; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; + startInEdit?: boolean; + onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; +function Field({ label, value }: { label: string; value?: string }) { + const display = !value || value.trim() === "" ? "—" : value; return ( - + {label} - - {display} + + {display} ); } -function TechnicianEntry({ - contactName, - phone, - email, - preferredContact, - tradeSpecialties, - isActive, - totalJobs, -}: { - contactName: string; - phone: string; - email: string; - preferredContact?: string; - tradeSpecialties: string; - isActive: boolean; - totalJobs: number; -}) { - const trades = splitTrades(tradeSpecialties); - return ( - - - - - {contactName || "—"} - - - - {totalJobs} jobs - - - - {Boolean(phone) && ( - - {phone} - - )} - {Boolean(email) && ( - - {email} - - )} - - - {Boolean(preferredContact) && ( - - Prefers {preferredContact} - - )} - {trades.map((trade, index) => ( - - ))} - - - - ); -} - function DrawerHeader({ roster, vendor, + technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; + technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -141,23 +87,18 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 3, + px: 2.5, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {roster?.name || vendor?.companyName || "Vendor company"} - - - {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} - + + {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} + + + {roster?.name || vendor?.companyName || "Vendor details"} + @@ -167,18 +108,19 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const mapsUrl = roster.googleMapsUrl; + const address = [roster.address, roster.city, roster.state, roster.zip] + .filter(Boolean) + .join(", "); + return ( + - - - {Boolean(mapsUrl) && ( + + + {Boolean(roster.googleMapsUrl) && ( - Technicians - {roster.technicians.length === 0 ? ( - - No technicians assigned to this company. - - ) : ( - - {roster.technicians.map((technician) => ( - + + + + Trade + + + {trades.length > 0 ? ( + trades.map((trade, index) => ( + + )) + ) : ( + — + )} + + + + + {technician.totalJobs ?? 0} + + Total Jobs + + + + + Status + + - ))} - - )} + + + ); } -function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { +function DrawerBody({ + roster, + technician, +}: { + roster: VendorCompanyRoster; + technician?: VendorRosterTechnician; +}) { return ( - - Company - + + + Company + + - + {technician ? ( + + ) : ( + + No technicians assigned to this company. + + )} {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { - const navigate = useNavigate(); +function DrawerEditor({ + vendor, + onClose, + onSaved, + onRequestDeactivation = () => undefined, +}: { + vendor: VendorListItem; + onClose: () => void; + onSaved: () => void; + onRequestDeactivation: (vendor: VendorListItem) => void; +}) { + const form = useVendorRosterForm({ + mode: "update", + vendorId: vendor.id ?? undefined, + companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, + onSuccess: onSaved, + }); + const roster = form.roster; + const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; + const selectedIndex = technicians.findIndex( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + ); + const selectedTotalJobs = + roster?.technicians.find( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + )?.totalJobs ?? vendor.totalJobs; + + if (form.isLoading) { + return ; + } + if (form.isError || !roster) { + return ( + + Failed to load vendor company. Try again. + + ); + } + + return ( + + + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( + + )} + + {selectedIndex >= 0 && ( + <> + + + + {selectedTotalJobs} + + Total Jobs + + + ( + + {field.value ? "Active" : "Inactive"} + { + if (field.value && !checked) onRequestDeactivation(vendor); + else field.onChange(checked); + }} + /> + + )} + /> + + + )} + + + + + + + ); +} + +function resolveTechnician( + roster: VendorCompanyRoster | undefined, + vendor: VendorListItem | null, +): VendorRosterTechnician | undefined { + if (!roster || !vendor) return undefined; + + const match = roster.technicians.find( + (technician) => + vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), + ); + if (match) return match; + if (!vendor.contactName) return undefined; + + return { + id: vendor.id ?? undefined, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + tradeSpecialties: vendor.tradeSpecialties, + isActive: vendor.isActive, + totalJobs: vendor.totalJobs, + }; +} + +export function VendorDetailDrawer({ + open, + vendor, + startInEdit = false, + onRequestDeactivation = () => undefined, + onClose, +}: VendorDetailDrawerProps) { + const [isEditing, setIsEditing] = useState(startInEdit); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer !hasVendorId ? (companyId ?? undefined) : undefined, ); - const editHref = hasVendorId - ? `/vendors/edit/${vendorId}` - : `/vendors/company/${companyId ?? ""}`; + const selectedTechnician = resolveTechnician(roster, vendor); + + useEffect(() => { + if (open) setIsEditing(startInEdit); + }, [open, startInEdit, vendor]); useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); + if (event.key === "Escape" && !isEditing) onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [onClose, open]); + }, [isEditing, onClose, open]); return ( { + if (reason === "escapeKeyDown" && isEditing) return; + onClose(); + }} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer }} > - + - {isLoading ? ( + {isEditing && vendor ? ( + setIsEditing(false)} + onRequestDeactivation={onRequestDeactivation} + /> + ) : isLoading ? ( @@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer ) : roster ? ( <> - - navigate(editHref)} /> + + setIsEditing(true)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx new file mode 100644 index 00000000..c9be7d45 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -0,0 +1,252 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; + +type DraftSetter = Dispatch>; + +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + +function CompanyFilter({ + options, + draft, + setDraft, +}: { + options: VendorFacetCompany[]; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + const allSelected = draft.companies.length === options.length && options.length > 0; + return ( + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={options.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +interface FilterProps { + options: T[]; + draft: VendorFilters; + setDraft: DraftSetter; +} + +function LocationFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={options.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobCountFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={options.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFilterOptions({ + facets, + draft, + setDraft, +}: { + facets: VendorFacets | undefined; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index ae4b51ba..e3b91cf5 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,32 +1,9 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; -import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import { useEffect, useState } from "react"; import CloseIcon from "@mui/icons-material/Close"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; -import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - Checkbox, - Chip, - Drawer, - IconButton, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, -} from "@/domain/vendors/types/vendor"; +import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import { VendorFilterOptions } from "./vendor-filter-options"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps { const DRAWER_WIDTH = 360; -function FilterSection({ - title, - icon, - children, -}: { - title: string; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - export function VendorFiltersDrawer({ open, onClose, @@ -74,23 +25,12 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) { - setDraft(appliedFilters); - } + if (open) setDraft(appliedFilters); }, [open, appliedFilters]); - const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); - const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); - const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); - const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); - const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; - const handleClear = () => { - setDraft(emptyVendorFilters); - }; - const handleApply = () => { onApply(draft); onClose(); @@ -133,165 +73,7 @@ export function VendorFiltersDrawer({
    - }> - - - Select vendor companies - - - - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={companyOptions.filter((option) => draft.companies.includes(option.name))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetCompany)[]).map((entry) => - typeof entry === "string" ? entry : entry.name, - ); - setDraft((current) => ({ ...current, companies: next })); - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.label === value.label - } - value={locationOptions.filter((option) => draft.locations.includes(option.label))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetLocation)[]).map((entry) => - typeof entry === "string" ? entry : entry.label, - ); - setDraft((current) => ({ ...current, locations: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.id === value.id - } - value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => - typeof entry === "string" ? entry : entry.id, - ); - setDraft((current) => ({ ...current, jobBuckets: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - +
    `1px solid ${theme.palette.divider}`, }} > -
    diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index 32f91675..d6d5cbf7 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -28,6 +28,7 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; + showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -54,6 +55,7 @@ function CompanyNameField({ )} @@ -119,10 +122,7 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - - Company - - + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message ?? "Phone or email required"} + helperText={errors.companyPhone?.message} fullWidth /> )} @@ -155,7 +155,8 @@ function CompanyFields({ render={({ field }) => ( } + render={({ field }) => ( + + )} /> - + ( )} @@ -206,7 +214,14 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -221,6 +236,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; + showStatus: boolean; } function TechnicianRow({ @@ -230,12 +246,13 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, + showStatus, }: TechnicianRowProps) { return ( - Technician {index + 1} + {index + 1} - TECHNICIAN @@ -256,7 +273,8 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -292,26 +310,29 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth + sx={{ display: "none" }} /> )} /> - ( - field.onChange(checked)} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> + {showStatus && ( + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + )} ); } @@ -320,10 +341,12 @@ function TechniciansFieldArray({ control, errors, tradeOptions, + showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; + showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -366,6 +389,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} + showStatus={showStatus} /> )) )} @@ -375,12 +399,17 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [] } = props; + const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; return ( - + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index a6ee3797..a8203ecb 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,14 +1,8 @@ -import { - Button, - Chip, - CircularProgress, - Stack, - TablePagination, - TextField, - Typography, -} from "@mui/material"; -import { SlidersHorizontal } from "lucide-react"; +import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; +import { Plus, SlidersHorizontal } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { @@ -36,30 +30,40 @@ export function VendorsListToolbar({ }: VendorsListToolbarProps) { return ( - onSearchChange(e.target.value)} - placeholder="Search name, company, trade..." - className="min-w-[260px]" + onChange={onSearchChange} + placeholder="Search name, company, trade…" + aria-label="Search vendors" + inputClassName="h-8 py-0 text-[13px]" /> } + icon={} onClick={onOpenFilters} aria-label="Open vendor filters" badge={appliedFilterCount} + className="w-[88.609375px] text-[13px]" > Filters - - {Boolean(isFetching && !isLoading) && } + } + onClick={onAddVendor} + className="w-[115.546875px] border-0 px-3 text-[13px]" + > + Add Vendor + + {isFetching && !isLoading && ( + + )} } /> @@ -74,16 +78,21 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; + resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ + chips, + resultCount, + onClearAll, +}: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - Applied: - + + {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "vendor entry" : "vendor entries"} + {totalCount === 1 ? "technician" : "technicians"} {isFiltered && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[12, 24, 48]} - /> + + + {activeCount} active · {inactiveCount} inactive + + {totalCount > pageSize && ( + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[24, 48, 96]} + /> + )} ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 5296c5f6..db588cff 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,11 +1,10 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Chip, + Box, CircularProgress, IconButton, Link, @@ -18,8 +17,8 @@ import { TableHead, TableRow, Tooltip, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -30,9 +29,6 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", - "Status", - "View", - "Edit", ] as const; function getInitials(name: string): string { @@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } +function VendorStatus({ isActive }: { isActive: boolean }) { + return ( + + + {isActive ? "Active" : "Inactive"} + + ); +} + interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { - const navigate = useNavigate(); +function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo onKeyDown={handleKeyDown} > - {row.companyName || "—"} + + {row.companyName || "—"} + - + {getInitials(row.contactName)} - {row.contactName || "—"} + + {row.contactName || "—"} + - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && } + {extraTrades > 0 && ( + + +{extraTrades} + + )} )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -127,7 +163,12 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo {row.email ? ( - + {row.email} ) : ( @@ -136,62 +177,39 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo {row.totalJobs ?? 0} - + - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + { event.stopPropagation(); - navigate( - row.id != null - ? `/vendors/edit/${row.id}` - : `/vendors/company/${row.companyId ?? ""}`, - ); + onOpenEdit(row); }} > - {hasVendorId && row.isActive && ( - - { - event.stopPropagation(); - onOpenDeactivation(row); - }} - > - - - - )} @@ -200,54 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo interface VendorsTableProps { rows: VendorListItem[]; + isActive: boolean; + isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } export function VendorsTable({ rows, + isActive, + isFiltered, isLoading, showError, onOpenDetail, - onOpenDeactivation, + onOpenEdit, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; + const columnCount = TABLE_COLUMNS.length + 2; return ( - - + +
    + + + + + + + + + + + {TABLE_COLUMNS.map((column) => ( - + {column} ))} + Status + {isLoading ? ( - + ) : showEmptyRow ? ( - - - No vendors match the current filters. - + + + + + {`No ${isActive ? "active" : "inactive"} technicians found`} + + + {isFiltered + ? "Try a different search, or add a new vendor." + : "Add a vendor to get started."} + + ) : ( @@ -256,7 +338,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenDeactivation={onOpenDeactivation} + onOpenEdit={onOpenEdit} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a084b59b..48447ad6 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,16 +23,91 @@ interface AppliedChip { label: string; } +function statusTabLabel(label: string, count: number) { + return ( + + {label} + + {count} + + + ); +} + +function VendorStatusTabs({ + isActive, + activeCount, + inactiveCount, + onChange, +}: { + isActive: boolean; + activeCount: number; + inactiveCount: number; + onChange: (isActive: boolean) => void; +}) { + return ( + onChange(value === "active")} + aria-label="Vendor status" + sx={{ + minHeight: 36, + flexShrink: 0, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + backgroundColor: "var(--color-bg-surface)", + mx: -2, + px: 1.5, + "& .MuiTab-root": { + minHeight: 36, + px: 2, + py: 0, + fontSize: 13, + fontWeight: 500, + textTransform: "none", + }, + "& .MuiTab-root:first-of-type": { width: 102.84375 }, + "& .Mui-selected": { fontWeight: 600 }, + "& .MuiTabs-indicator": { height: 2 }, + }} + > + + + + ); +} + function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); - filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); - filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); + filters.companies.forEach((value) => + chips.push({ category: "companies", value, label: `Company: ${value}` }), + ); + filters.trades.forEach((value) => + chips.push({ category: "trades", value, label: `Trade: ${value}` }), + ); + filters.locations.forEach((value) => + chips.push({ category: "locations", value, label: `Location: ${value}` }), + ); filters.jobBuckets.forEach((value) => - chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + chips.push({ + category: "jobBuckets", + value, + label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, + }), ); return chips; } @@ -42,7 +117,12 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const deactivation = useVendorDeactivation(); + const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); + const closeDetail = () => { + setDetailTarget(null); + setDetailStartsInEdit(false); + }; + const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -61,7 +141,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - - filters.setIsActiveWithReset(value === "active") - } - aria-label="Vendor status" - > - - - + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -102,10 +179,18 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={setDetailTarget} - onOpenDeactivation={deactivation.open} + onOpenDetail={(row) => { + setDetailStartsInEdit(false); + setDetailTarget(row); + }} + onOpenEdit={(row) => { + setDetailStartsInEdit(true); + setDetailTarget(row); + }} /> setDetailTarget(null)} + startInEdit={detailStartsInEdit} + onRequestDeactivation={deactivation.open} + onClose={closeDetail} /> {title} - + {subtitle} diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index e083f352..2e9f8f1f 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -5,6 +5,7 @@ import { useController } from "react-hook-form"; import { beforeEach, describe, expect, it, vi } from "vitest"; const rosterGet = vi.fn(); +const saveMutate = vi.fn(); vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, @@ -28,7 +29,10 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => const actual = await vi.importActual< typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") >("@/domain/vendors/use-cases/use-save-vendor-company-roster"); - return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; + return { + ...actual, + useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }), + }; }); import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; @@ -101,6 +105,7 @@ function deferRoster(): { describe("useVendorRosterForm load-error handling", () => { beforeEach(() => { rosterGet.mockReset(); + saveMutate.mockReset(); }); it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { @@ -196,6 +201,44 @@ describe("useVendorRosterForm load-error handling", () => { }); }); +describe("useVendorRosterForm prototype defaults", () => { + it("does not persist the prototype's untouched optional technician card", () => { + const { result } = renderHook( + () => useVendorRosterForm({ mode: "create", startWithTechnician: true }), + { wrapper: makeWrapper(createClient()) }, + ); + + act(() => { + result.current.submit({ + name: "Independent Vendor LLC", + companyPhone: "(314) 555-0199", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, + }, + ], + }); + }); + + expect(saveMutate).toHaveBeenCalledWith( + expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }), + expect.any(Object), + ); + }); +}); + describe("useVendorRosterForm stale-selection handling", () => { beforeEach(() => { rosterGet.mockReset(); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index a55ddaf0..df329a4a 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -49,11 +49,11 @@ function rosterWith(technicians: Array>) { }; } -describe("VendorDetailDrawer preferred-contact display", () => { +describe("VendorDetailDrawer selected-technician display", () => { it("renders no preference label when preferredContact is absent", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ - { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + { id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, ]), ); @@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => { withAuth: false, }); - expect(screen.getByText("Adam")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); }); - it("renders the preference label when an explicit preferredContact is present", () => { + it("keeps the retired preferred-contact value out of the detail view", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([ { - id: 7, + id: 1, contactName: "Adam", phone: "314-555-0198", preferredContact: "Email", @@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => { withAuth: false, }); - expect(screen.getByText("Prefers Email")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument(); + expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); + }); + + it("falls back to the clicked row when the roster has no matching stable id", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Adam Whyte", + phone: "999-999-9999", + isActive: true, + totalJobs: 99, + }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument(); + }); + + it("does not expose a status control for a different technician", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Different Technician", + phone: "314-555-0101", + email: "", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 10, + }, + ]), + ); + + renderWithProviders( + , + { route: "/vendors", withAuth: false }, + ); + + expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument(); }); }); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 64c71c69..a5fd7a64 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -1,21 +1,14 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); -const navigate = vi.fn(); - -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router"); - return { ...actual, useNavigate: () => navigate }; -}); - vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); @@ -61,6 +54,32 @@ const inactiveVendor = { isActive: false, }; +const activeRoster: VendorCompanyRoster = { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gatewayplumbing.com", + address: "", + city: "St. Louis", + state: "MO", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + preferredContact: "Email", + tradeSpecialties: "Backflow Preventers", + isActive: true, + totalJobs: 0, + }, + ], +}; + function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, @@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) { } function setupDefaults() { - useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); + useVendorCompanyRoster.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorDeactivationImpact.mockReturnValue({ data: undefined, isLoading: false, @@ -80,7 +105,7 @@ function setupDefaults() { } describe("VendorsListPage", () => { - it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { + it("renders prototype-aligned status and two row actions in both tabs", async () => { setupDefaults(); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], params.isActive ? 1 : 1); @@ -91,11 +116,20 @@ describe("VendorsListPage", () => { expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument(); expect(screen.getByText("St. Louis, MO")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "View vendor Gateway Plumbing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" })); expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument(); + expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument(); }); @@ -114,39 +148,18 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); + expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { setupDefaults(); useVendorCompanyRoster.mockReturnValue({ - data: { - companyId: "co-1", - rowVersion: "rv-1", - name: "Gateway Plumbing", - companyPhone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - address: "", - city: "St. Louis", - state: "MO", - zip: "", - googleMapsUrl: "", - notes: "", - technicians: [ - { - id: 1, - contactName: "Adam Whyte", - phone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - preferredContact: "Email", - tradeSpecialties: "Backflow Preventers", - isActive: true, - }, - ], - }, + data: activeRoster, isLoading: false, + isError: false, error: null, + refetch: vi.fn(), }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); @@ -161,11 +174,18 @@ describe("VendorsListPage", () => { row.focus(); await userEvent.keyboard("{Enter}"); - expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument(); }); it("blocks deactivation when the preflight reports open work orders", async () => { setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorDeactivationImpact.mockReturnValue({ data: { vendorId: 1, @@ -189,7 +209,8 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); expect( screen.getByText(/cannot be deactivated because it still has open work orders/), @@ -199,9 +220,51 @@ describe("VendorsListPage", () => { expect(mutate).not.toHaveBeenCalled(); }); - it("navigates to the vendor edit route when the table Edit control is clicked", async () => { + it("preserves inline edits when deactivation is cancelled", async () => { setupDefaults(); - navigate.mockClear(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + useVendorDeactivationImpact.mockReturnValue({ + data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] }, + isLoading: false, + error: null, + }); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + const company = screen.getByRole("textbox", { name: "Company" }); + await userEvent.clear(company); + await userEvent.type(company, "Draft Company Name"); + await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await userEvent.click( + within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", { + name: "Cancel", + }), + ); + + expect(company).toBeInTheDocument(); + expect(company).toHaveValue("Draft Company Name"); + }); + + it("opens inline edit when the table Edit control is clicked", async () => { + setupDefaults(); + useVendorCompanyRoster.mockReturnValue({ + data: activeRoster, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([activeVendor], 1) : result([], 0); @@ -211,16 +274,20 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); - expect( - screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); }); - it("navigates to the company route when editing a vendor without a vendor id", async () => { + it("opens inline company edit when the row has no vendor id", async () => { setupDefaults(); - navigate.mockClear(); const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; + useVendorCompanyRoster.mockReturnValue({ + data: { ...activeRoster, companyId: "co-9", technicians: [] }, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); @@ -230,6 +297,7 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); + expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); }); });