diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 2bbda41b..ad1a89e8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); - await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); - await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); + const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("font-family", /Montserrat/); + await expect(heading).toHaveCSS("font-size", "30px"); + await expect(heading).toHaveCSS("font-weight", "800"); + await expect(heading).toHaveCSS("line-height", "33px"); + await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); + await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", - "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); + const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); + expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); + const tableContainerBox = await page.locator("table").locator("..").boundingBox(); + expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); + expect(tableContainerBox?.height).toBeCloseTo(738, 0); + expect((await page.locator("table").boundingBox())?.width).toBe(1550); + expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( + 47, + ); + expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active technicians")).toBeVisible(); + await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); - await page.getByPlaceholder("Search name, company, trade...").fill("metro"); + await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); - await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); + await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await page.getByPlaceholder("Search name, company, trade...").clear(); + await page.getByPlaceholder("Search name, company, trade…").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); - await page.getByRole("button", { name: "Apply" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText("1 technician · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); - await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( - "history will be preserved", - ); - await page.getByRole("button", { name: "Deactivate", exact: true }).click(); - await expect.poll(() => state.deletedId).toBe("1"); - await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); + await expect(page.getByText("No inactive technicians found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "+ Add Vendor" }).click(); - await expect( - page.getByText("Register a company and add zero, one, or multiple technicians."), - ).toBeVisible(); + await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); + await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); + await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( + "https://maps.google.com/gateway", + ); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician Name").last().fill("New Technician"); + await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); - await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); + await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByLabel("Notes").fill("Updated in browser E2E"); - await page.getByRole("button", { name: "Save" }).click(); + await expect(page).toHaveURL(/\/vendors$/); + await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); - await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByRole("switch", { name: "Inactive" }).first().click(); - await page.getByRole("button", { name: "Save" }).click(); + await detailDrawer.getByRole("switch", { name: "Active status" }).click(); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); + await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "+ Add Vendor" }); + const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await page.getByLabel("Company Phone (optional)").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); @@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -555,6 +574,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/scripts/governance-baseline.json b/scripts/governance-baseline.json index 18c4ae08..3862e898 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -2,11 +2,5 @@ "version": 1, "purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.", "maxFileLines": 500, - "godfileDebt": [ - { - "path": "src/app/(protected)/vendors/index.tsx", - "maxLines": 612, - "reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction." - } - ] + "godfileDebt": [] } diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index 2409814f..b63291be 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,9 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(options?: { - onSuccess?: () => void; -}): VendorDeactivationState { +export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -43,7 +41,7 @@ export function useVendorDeactivation(options?: { deleteVendor.mutate(target.id, { onSuccess: () => { setTarget(null); - options?.onSuccess?.(); + onSuccess?.(); }, onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index 9385a834..fdc90685 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); + const [pageSize, setPageSize] = useState(48); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index b4c3a51f..6bf89b1f 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, + emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -43,6 +44,7 @@ export interface VendorRosterFormProps { vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; + startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -73,6 +75,7 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, + startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -87,9 +90,16 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); + const createDefaults = useMemo( + () => ({ + ...emptyVendorCompanyRosterForm, + technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], + }), + [startWithTechnician], + ); const form = useForm({ resolver, - defaultValues: emptyVendorCompanyRosterForm, + defaultValues: createDefaults, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -114,10 +124,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(emptyVendorCompanyRosterForm); + reset(createDefaults); resetSelection(); clearConflict(); - }, [clearConflict, reset, resetSelection]); + }, [clearConflict, createDefaults, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index d0025662..31804a3c 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,7 +21,11 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const form = useVendorRosterForm({ + mode: "create", + onSuccess: onClose, + startWithTechnician: true, + }); const { resetForm } = form; useEffect(() => { @@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="md" + maxWidth="sm" aria-labelledby="vendor-create-dialog-title" - slotProps={{ paper: { role: "dialog" } }} + slotProps={{ + paper: { + role: "dialog", + sx: { maxHeight: "88vh", borderRadius: 2 }, + }, + }} > - Add Vendor Company - - Register a company and add zero, one, or multiple technicians. - + + Add Vendor + + + Register a vendor company and its technicians. + - + {form.loadErrorMessage !== null && ( @@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving || !form.isValid} + disabled={form.isSaving} > - {form.isSaving ? : "Add Vendor Company"} + {form.isSaving ? : "Add Vendor"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index ad7ffa29..74d6ef02 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useEffect, useState, type ReactNode } from "react"; +import { Controller, useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,15 +13,22 @@ import { Drawer, IconButton, Link, - List, - ListItem, Stack, - Typography, + Switch, } from "@mui/material"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; +import { Text } from "@/components/ui/text"; +import type { + VendorCompanyRoster, + VendorListItem, + VendorRosterTechnician, +} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 440; +const DRAWER_WIDTH = 420; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; + startInEdit?: boolean; + onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; +function Field({ label, value }: { label: string; value?: string }) { + const display = !value || value.trim() === "" ? "—" : value; return ( - + {label} - - {display} + + {display} ); } -function TechnicianEntry({ - contactName, - phone, - email, - preferredContact, - tradeSpecialties, - isActive, - totalJobs, -}: { - contactName: string; - phone: string; - email: string; - preferredContact?: string; - tradeSpecialties: string; - isActive: boolean; - totalJobs: number; -}) { - const trades = splitTrades(tradeSpecialties); - return ( - - - - - {contactName || "—"} - - - - {totalJobs} jobs - - - - {Boolean(phone) && ( - - {phone} - - )} - {Boolean(email) && ( - - {email} - - )} - - - {Boolean(preferredContact) && ( - - Prefers {preferredContact} - - )} - {trades.map((trade, index) => ( - - ))} - - - - ); -} - function DrawerHeader({ roster, vendor, + technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; + technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -141,23 +87,18 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 3, + px: 2.5, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {roster?.name || vendor?.companyName || "Vendor company"} - - - {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} - + + {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} + + + {roster?.name || vendor?.companyName || "Vendor details"} + @@ -167,18 +108,19 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const mapsUrl = roster.googleMapsUrl; + const address = [roster.address, roster.city, roster.state, roster.zip] + .filter(Boolean) + .join(", "); + return ( + - - - {Boolean(mapsUrl) && ( + + + {Boolean(roster.googleMapsUrl) && ( - Technicians - {roster.technicians.length === 0 ? ( - - No technicians assigned to this company. - - ) : ( - - {roster.technicians.map((technician) => ( - + + + + Trade + + + {trades.length > 0 ? ( + trades.map((trade, index) => ( + + )) + ) : ( + — + )} + + + + + {technician.totalJobs ?? 0} + + Total Jobs + + + + + Status + + - ))} - - )} + + + ); } -function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { +function DrawerBody({ + roster, + technician, +}: { + roster: VendorCompanyRoster; + technician?: VendorRosterTechnician; +}) { return ( - - Company - + + + Company + + - + {technician ? ( + + ) : ( + + No technicians assigned to this company. + + )} {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { - const navigate = useNavigate(); +function DrawerEditor({ + vendor, + onClose, + onSaved, + onRequestDeactivation = () => undefined, +}: { + vendor: VendorListItem; + onClose: () => void; + onSaved: () => void; + onRequestDeactivation: (vendor: VendorListItem) => void; +}) { + const form = useVendorRosterForm({ + mode: "update", + vendorId: vendor.id ?? undefined, + companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, + onSuccess: onSaved, + }); + const roster = form.roster; + const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; + const selectedIndex = technicians.findIndex( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + ); + const selectedTotalJobs = + roster?.technicians.find( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + )?.totalJobs ?? vendor.totalJobs; + + if (form.isLoading) { + return ; + } + if (form.isError || !roster) { + return ( + + Failed to load vendor company. Try again. + + ); + } + + return ( + + + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( + + )} + + {selectedIndex >= 0 && ( + <> + + + + {selectedTotalJobs} + + Total Jobs + + + ( + + {field.value ? "Active" : "Inactive"} + { + if (field.value && !checked) onRequestDeactivation(vendor); + else field.onChange(checked); + }} + /> + + )} + /> + + + )} + + + + + + + ); +} + +function resolveTechnician( + roster: VendorCompanyRoster | undefined, + vendor: VendorListItem | null, +): VendorRosterTechnician | undefined { + if (!roster || !vendor) return undefined; + + const match = roster.technicians.find( + (technician) => + vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), + ); + if (match) return match; + if (!vendor.contactName) return undefined; + + return { + id: vendor.id ?? undefined, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + tradeSpecialties: vendor.tradeSpecialties, + isActive: vendor.isActive, + totalJobs: vendor.totalJobs, + }; +} + +export function VendorDetailDrawer({ + open, + vendor, + startInEdit = false, + onRequestDeactivation = () => undefined, + onClose, +}: VendorDetailDrawerProps) { + const [isEditing, setIsEditing] = useState(startInEdit); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer !hasVendorId ? (companyId ?? undefined) : undefined, ); - const editHref = hasVendorId - ? `/vendors/edit/${vendorId}` - : `/vendors/company/${companyId ?? ""}`; + const selectedTechnician = resolveTechnician(roster, vendor); + + useEffect(() => { + if (open) setIsEditing(startInEdit); + }, [open, startInEdit, vendor]); useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); + if (event.key === "Escape" && !isEditing) onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [onClose, open]); + }, [isEditing, onClose, open]); return ( { + if (reason === "escapeKeyDown" && isEditing) return; + onClose(); + }} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer }} > - + - {isLoading ? ( + {isEditing && vendor ? ( + setIsEditing(false)} + onRequestDeactivation={onRequestDeactivation} + /> + ) : isLoading ? ( @@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer ) : roster ? ( <> - - navigate(editHref)} /> + + setIsEditing(true)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx new file mode 100644 index 00000000..c9be7d45 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -0,0 +1,252 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; + +type DraftSetter = Dispatch>; + +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + +function CompanyFilter({ + options, + draft, + setDraft, +}: { + options: VendorFacetCompany[]; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + const allSelected = draft.companies.length === options.length && options.length > 0; + return ( + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={options.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +interface FilterProps { + options: T[]; + draft: VendorFilters; + setDraft: DraftSetter; +} + +function LocationFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={options.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobCountFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={options.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFilterOptions({ + facets, + draft, + setDraft, +}: { + facets: VendorFacets | undefined; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index 86cb612a..b18c568c 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import CloseIcon from "@mui/icons-material/Close"; import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; import type { VendorFacets } from "@/domain/vendors/types/vendor"; +import { VendorFilterOptions } from "./vendor-filter-options"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; -import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields"; interface VendorFiltersDrawerProps { open: boolean; @@ -30,11 +30,6 @@ export function VendorFiltersDrawer({ } }, [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; @@ -84,16 +79,7 @@ export function VendorFiltersDrawer({
    - +
    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 d54c3016..d6d5cbf7 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -1,10 +1,4 @@ -import { - Controller, - useFieldArray, - useWatch, - type Control, - type FieldErrors, -} from "react-hook-form"; +import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form"; import AddIcon from "@mui/icons-material/Add"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import { @@ -34,7 +28,7 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; - onRequestDeactivate?: (technician: RosterTechnicianValues) => void; + showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -61,6 +55,7 @@ function CompanyNameField({ )} @@ -126,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 /> )} @@ -162,7 +155,8 @@ function CompanyFields({ render={({ field }) => ( } + render={({ field }) => ( + + )} /> - + ( )} @@ -213,7 +214,14 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -228,7 +236,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; - onRequestDeactivate?: (technician: RosterTechnicianValues) => void; + showStatus: boolean; } function TechnicianRow({ @@ -238,15 +246,13 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, - onRequestDeactivate, + showStatus, }: TechnicianRowProps) { - const technician = useWatch({ control, name: `technicians.${index}` }); - return ( - Technician {index + 1} + {index + 1} - TECHNICIAN @@ -267,7 +273,8 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -303,32 +310,29 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth + sx={{ display: "none" }} /> )} /> - ( - { - if (!checked && technician?.id != null && onRequestDeactivate != null) { - onRequestDeactivate(technician); - return; - } - field.onChange(checked); - }} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> + {showStatus && ( + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + )} ); } @@ -337,12 +341,12 @@ function TechniciansFieldArray({ control, errors, tradeOptions, - onRequestDeactivate, + showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; - onRequestDeactivate?: (technician: RosterTechnicianValues) => void; + showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -385,7 +389,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} - onRequestDeactivate={onRequestDeactivate} + showStatus={showStatus} /> )) )} @@ -395,7 +399,7 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], onRequestDeactivate } = props; + const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; return ( @@ -404,7 +408,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { control={control} errors={errors} tradeOptions={tradeOptions} - onRequestDeactivate={onRequestDeactivate} + showStatus={showTechnicianStatus} /> ); diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx index 5b10f89c..f0316957 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -1,44 +1,17 @@ import type { ReactNode } from "react"; import { useNavigate } from "react-router"; import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; -import { VendorDeactivationDialog } from "./vendor-deactivation-dialog"; import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; -import { useVendorDeactivation } from "./use-vendor-deactivation"; import { useVendorRosterForm } from "./use-vendor-roster-form"; -import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; interface VendorRosterPageProps { vendorId?: string; companyId?: string; } -function toDeactivationListItem( - roster: VendorCompanyRoster, - technician: RosterTechnicianValues, -): VendorListItem { - return { - id: technician.id ?? null, - companyId: roster.companyId, - companyName: roster.name, - contactName: technician.contactName, - email: technician.email, - phone: technician.phone, - companyPhone: roster.companyPhone, - googleMapsUrl: roster.googleMapsUrl, - notes: roster.notes, - totalJobs: 0, - city: roster.city, - state: roster.state, - tradeSpecialties: technician.tradeSpecialties, - isActive: technician.isActive, - preferredContact: technician.preferredContact ?? "Phone", - }; -} - function PageShell({ title, subtitle, @@ -101,9 +74,6 @@ function submitLabel(isSaving: boolean, isEdit: boolean): string { export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { const navigate = useNavigate(); const isEdit = vendorId !== undefined || companyId !== undefined; - const deactivation = useVendorDeactivation({ - onSuccess: () => navigate("/vendors"), - }); const form = useVendorRosterForm({ mode: isEdit ? "update" : "create", vendorId, @@ -111,11 +81,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa onSuccess: () => navigate("/vendors"), }); - const handleRequestDeactivate = (technician: RosterTechnicianValues) => { - if (!form.roster || technician.id == null) return; - deactivation.open(toDeactivationListItem(form.roster, technician)); - }; - if (isEdit && form.isLoading) { return ( @@ -159,7 +124,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa control={form.control} errors={form.errors} tradeOptions={form.trades} - onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined} {...companySelectionProps} /> @@ -181,17 +145,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa - - ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index e37131c4..b8ad2a9c 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,13 +1,9 @@ -import FilterListIcon from "@mui/icons-material/FilterList"; -import { - Button, - Chip, - CircularProgress, - Stack, - TablePagination, - TextField, - Typography, -} from "@mui/material"; +import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; +import { Plus, SlidersHorizontal } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; +import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { globalCount: number; @@ -33,55 +29,46 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - - Vendors / Technicians - - - {globalCount} vendor entries · {activeCount} active - - {isFetching && !isLoading && ( - - )} - - - - onSearchChange(e.target.value)} - placeholder="Search name, company, trade..." - className="min-w-[260px]" - aria-label="Search vendors" - /> - - - - + + } + /> ); } @@ -93,16 +80,21 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; + resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ + chips, + resultCount, + onClearAll, +}: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - Applied: - + + {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "vendor entry" : "vendor entries"} + {totalCount === 1 ? "technician" : "technicians"} {isFiltered && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[12, 24, 48]} - /> + + + {activeCount} active · {inactiveCount} inactive + + {totalCount > pageSize && ( + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[24, 48, 96]} + /> + )} ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 802fff8a..db588cff 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,11 +1,10 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Chip, + Box, CircularProgress, IconButton, Link, @@ -18,8 +17,8 @@ import { TableHead, TableRow, Tooltip, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -30,9 +29,6 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", - "Status", - "View", - "Edit", ] as const; function getInitials(name: string): string { @@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } +function VendorStatus({ isActive }: { isActive: boolean }) { + return ( + + + {isActive ? "Active" : "Inactive"} + + ); +} + interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { - const navigate = useNavigate(); +function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo onKeyDown={handleKeyDown} > - {row.companyName || "—"} + + {row.companyName || "—"} + - + {getInitials(row.contactName)} - {row.contactName || "—"} + + {row.contactName || "—"} + - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && } + {extraTrades > 0 && ( + + +{extraTrades} + + )} )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + { event.stopPropagation(); - navigate( - row.id != null - ? `/vendors/edit/${row.id}` - : `/vendors/company/${row.companyId ?? ""}`, - ); + onOpenEdit(row); }} > - {hasVendorId && row.isActive && ( - - { - event.stopPropagation(); - onOpenDeactivation(row); - }} - > - - - - )} @@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo interface VendorsTableProps { rows: VendorListItem[]; + isActive: boolean; + isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } export function VendorsTable({ rows, + isActive, + isFiltered, isLoading, showError, onOpenDetail, - onOpenDeactivation, + onOpenEdit, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; + const columnCount = TABLE_COLUMNS.length + 2; return ( - - + +
    + + + + + + + + + + + {TABLE_COLUMNS.map((column) => ( - + {column} ))} + Status + {isLoading ? ( - + ) : showEmptyRow ? ( - - - No vendors match the current filters. - + + + + + {`No ${isActive ? "active" : "inactive"} technicians found`} + + + {isFiltered + ? "Try a different search, or add a new vendor." + : "Add a vendor to get started."} + + ) : ( @@ -252,7 +338,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenDeactivation={onOpenDeactivation} + onOpenEdit={onOpenEdit} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a084b59b..48447ad6 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,16 +23,91 @@ interface AppliedChip { label: string; } +function statusTabLabel(label: string, count: number) { + return ( + + {label} + + {count} + + + ); +} + +function VendorStatusTabs({ + isActive, + activeCount, + inactiveCount, + onChange, +}: { + isActive: boolean; + activeCount: number; + inactiveCount: number; + onChange: (isActive: boolean) => void; +}) { + return ( + onChange(value === "active")} + aria-label="Vendor status" + sx={{ + minHeight: 36, + flexShrink: 0, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + backgroundColor: "var(--color-bg-surface)", + mx: -2, + px: 1.5, + "& .MuiTab-root": { + minHeight: 36, + px: 2, + py: 0, + fontSize: 13, + fontWeight: 500, + textTransform: "none", + }, + "& .MuiTab-root:first-of-type": { width: 102.84375 }, + "& .Mui-selected": { fontWeight: 600 }, + "& .MuiTabs-indicator": { height: 2 }, + }} + > + + + + ); +} + function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); - filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); - filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); + filters.companies.forEach((value) => + chips.push({ category: "companies", value, label: `Company: ${value}` }), + ); + filters.trades.forEach((value) => + chips.push({ category: "trades", value, label: `Trade: ${value}` }), + ); + filters.locations.forEach((value) => + chips.push({ category: "locations", value, label: `Location: ${value}` }), + ); filters.jobBuckets.forEach((value) => - chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + chips.push({ + category: "jobBuckets", + value, + label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, + }), ); return chips; } @@ -42,7 +117,12 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const deactivation = useVendorDeactivation(); + const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); + const closeDetail = () => { + setDetailTarget(null); + setDetailStartsInEdit(false); + }; + const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -61,7 +141,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - - filters.setIsActiveWithReset(value === "active") - } - aria-label="Vendor status" - > - - - + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -102,10 +179,18 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={setDetailTarget} - onOpenDeactivation={deactivation.open} + onOpenDetail={(row) => { + setDetailStartsInEdit(false); + setDetailTarget(row); + }} + onOpenEdit={(row) => { + setDetailStartsInEdit(true); + setDetailTarget(row); + }} /> setDetailTarget(null)} + startInEdit={detailStartsInEdit} + onRequestDeactivation={deactivation.open} + onClose={closeDetail} /> void; + onViewExisting: (row: WorkOrderTableRow) => void; +}; + +export function DuplicateWoNumberDialog({ + open, + existing, + onDismiss, + onViewExisting, +}: DuplicateWoNumberDialogProps) { + const woNumber = existing?.woNumber ?? ""; + + return ( + + Duplicate Work Order Number + + + Work order number {woNumber} is already assigned to another work order. View the existing + record or dismiss to keep editing. + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 53c2f381..539a8427 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -12,12 +12,16 @@ export function buildInlineDraftCreateInput( const location = sites.find((s) => String(s.id) === String(draft.locationId)); const locationName = draft.location || draft.site || location?.name || ""; + const hasWeekTarget = Boolean(draft.targetWeek?.trim()); + const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn)); + return { locationId: String(draft.locationId || ""), locationName, siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), woNumber: draft.woNumber, - type: draft.type, + type: draft.type || "PM", + severity: draft.severity, assigneeId: draft.dispatcherId, assigneeName: assignee?.name ?? "", scheduledOn: draft.scheduledOn, @@ -35,7 +39,7 @@ export function buildInlineDraftCreateInput( pocName: draft.pocName, pocPhone: draft.pocPhone, pocNotes: draft.pocNotes, - scheduleWeekOnly: draft.scheduleWeekOnly, + scheduleWeekOnly, targetWeek: draft.targetWeek || "", }; } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index 417ffe22..6d78143e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -15,6 +15,8 @@ type DateCellProps = { targetWeek?: string; onSaveWeek?: (weekStart: string) => void; defaultMode?: "week" | "date"; + /** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */ + onClear?: () => void; }; export function DateCell({ @@ -26,6 +28,7 @@ export function DateCell({ targetWeek = "", onSaveWeek, defaultMode, + onClear, }: DateCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [cellMode, setCellMode] = useState<"week" | "date">( @@ -132,6 +135,21 @@ export function DateCell({ }} /> )} + {Boolean(onClear) && ( +
    + +
    + )} ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index ce4b3462..83d4297c 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -1,43 +1,30 @@ import { useEffect, useState } from "react"; - import { toast } from "react-toastify"; - +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; - +import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; import { HighlightText } from "./highlight-text"; type EditableWONumberCellProps = { value: string; - rescheduleCount: number; - carriedOver?: number; - q?: string; - currentId: string | number; - allOrders: WorkOrderTableRow[]; - onSave: (v: string) => void; - onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; function CounterPill({ label, count }: { label: string; count: number }) { - if (count < 2) { - return null; - } - + if (count < 2) return null; return ( @@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) { ); } +type WoNumberEditorProps = { + draft: string; + invalid: boolean; + isChecking: boolean; + onDraftChange: (value: string) => void; + onCommit: () => void; + onCancel: () => void; + onBlur: () => void; +}; + +function WoNumberEditor({ + draft, + invalid, + isChecking, + onDraftChange, + onCommit, + onCancel, + onBlur, +}: WoNumberEditorProps) { + return ( + onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(e) => e.target.select()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onCommit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + onBlur={onBlur} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + fontWeight: 700, + color: "var(--foreground)", + background: "var(--color-bg-surface)", + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + borderRadius: 4, + padding: "2px 4px", + width: 120, + }} + /> + ); +} + +async function resolveDuplicateWoNumber( + draft: string, + allOrders: WorkOrderTableRow[], + currentId: string | number, +) { + const local = findDuplicateWoNumberRow(draft, allOrders, currentId); + const remote = local ?? (await workOrdersApi.findByWoNumber(draft)); + return remote && String(remote.id) !== String(currentId) ? remote : undefined; +} + export function EditableWONumberCell({ value, - rescheduleCount, - carriedOver = 0, - q, - currentId, - allOrders, - onSave, - onDuplicateFound, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(value); + const [isChecking, setIsChecking] = useState(false); useEffect(() => { - if (!editing) { - setDraft(value); - } + if (!editing) setDraft(value); }, [value, editing]); - const findDuplicate = (d: string) => - allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId)); - const commit = () => { - if (draft === value) { + if (draft === value || isChecking) { setEditing(false); - return; } - if (draft.length !== 11) { toast("Work order number must be exactly 11 digits"); - return; } - - const duplicate = findDuplicate(draft); - - if (duplicate) { - onDuplicateFound?.(duplicate); - - setDraft(value); - - setEditing(false); - - return; - } - - onSave(draft); - - setEditing(false); + setIsChecking(true); + void (async () => { + try { + const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId); + if (duplicate) { + if (onDuplicateFound) { + onDuplicateFound(duplicate); + } else { + toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`); + } + setDraft(value); + return; + } + onSave(draft); + } catch { + toast.error("Unable to verify the work order number. Please try again."); + } finally { + setIsChecking(false); + setEditing(false); + } + })(); }; const cancel = () => { setDraft(value); - setEditing(false); }; const handleBlur = () => { + if (isChecking) return; if (draft !== value && draft.length === 11) { - const duplicate = findDuplicate(draft); - - if (duplicate) { - onDuplicateFound?.(duplicate); - - setDraft(value); - - setEditing(false); - - return; - } - - onSave(draft); + commit(); + return; } - setEditing(false); }; @@ -137,75 +169,34 @@ export function EditableWONumberCell({ return (
    {editing ? ( - setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} - onFocus={(e) => e.target.select()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - - commit(); - } else if (e.key === "Escape") { - e.preventDefault(); - - cancel(); - } - }} + ) : ( { e.stopPropagation(); - setEditing(true); }} className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" style={{ fontSize: 13, - fontWeight: 700, - color: value ? "var(--foreground)" : "var(--color-text-muted)", - borderRadius: 4, - padding: "2px 4px", }} > {value ? : "-"} )} - -
    ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx new file mode 100644 index 00000000..18e3092a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-badge.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from "@mui/material"; + +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityBadgeProps = { + severity: WorkOrderSeverity; + onClick?: (e: React.MouseEvent) => void; +}; + +export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) { + const lvl = SEVERITY_LEVELS[severity]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`; + + return ( + + + {lvl.label} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx new file mode 100644 index 00000000..d78961af --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx @@ -0,0 +1,86 @@ +import { Text } from "@/components/ui/text"; +import { + SEVERITY_LEVELS, + SEVERITY_TOKEN_COLOR, + WORK_ORDER_SEVERITIES, + type WorkOrderSeverity, +} from "@/domain/work-orders/types/work-order-severity"; + +type SeverityOptionsListProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) { + return ( +
    + {WORK_ORDER_SEVERITIES.map((n) => { + const lvl = SEVERITY_LEVELS[n]; + const color = SEVERITY_TOKEN_COLOR[lvl.token]; + const selected = value === n; + return ( + + ); + })} +
    + ); +} + +type SeverityFieldProps = { + value: WorkOrderSeverity | null; + onSelect: (severity: WorkOrderSeverity) => void; +}; + +export function SeverityField({ value, onSelect }: SeverityFieldProps) { + return ( +
    + + Severity (from APM) + + + Transcribe the severity already assigned on the client's ticket — this is not a + judgment call. + + +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx index 939b4ce4..89a703f6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -17,6 +17,8 @@ type SiteCellProps = { pocNotes?: string; sites: LocationOption[]; q?: string; + /** Inline create: editable POC + Site/POC required to confirm the modal. */ + createMode?: boolean; onSave: (patch: SitePatch) => void; }; @@ -31,6 +33,7 @@ export function SiteCell({ pocNotes, sites, q, + createMode = false, onSave, }: SiteCellProps) { const [open, setOpen] = useState(false); @@ -70,7 +73,15 @@ export function SiteCell({ )}
    ) : ( - — + + {createMode ? ( + <> + Site * + + ) : ( + "—" + )} + )} @@ -83,6 +94,7 @@ export function SiteCell({ pocPhone={pocPhone} pocNotes={pocNotes} sites={sites} + createMode={createMode} onSave={onSave} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx index 145250c2..61ca8f78 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields.tsx @@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = { pocName: string; pocPhone: string; pocNotes: string; + editable?: boolean; + showErrors?: boolean; + onPocNameChange?: (value: string) => void; + onPocPhoneChange?: (value: string) => void; + onPocNotesChange?: (value: string) => void; }; -export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) { +export function SiteDialogPocFields({ + pocName, + pocPhone, + pocNotes, + editable = false, + showErrors = false, + onPocNameChange, + onPocPhoneChange, + onPocNotesChange, +}: SiteDialogPocFieldsProps) { + const nameMissing = showErrors && !pocName.trim(); + const phoneMissing = showErrors && !pocPhone.trim(); + return (
    Point of Contact - Shown from site/work order records. Board edit saves site code only. + {editable + ? "Auto-filled from records — edit if outdated" + : "Shown from site/work order records. Board edit saves site code only."}
    - - +
    + onPocNameChange?.(e.target.value)} + /> + + Contact name is required + +
    +
    + onPocPhoneChange?.(e.target.value)} + /> + + Contact phone is required + +