From cfb0e7ea45f5dcdcf1cb6b09b161ddf29f72c322 Mon Sep 17 00:00:00 2001 From: Arthur Bassi <201806768+bassi-arthurr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:11 -0300 Subject: [PATCH] fix(work-orders): persist additionalContacts on create and board PATCH [SH-218] [recover] remove malicious eslint payload (was 3f372257) --- e2e/vendors/vendors.spec.ts | 114 ++--- .../_components/use-vendor-deactivation.ts | 7 +- .../_components/use-vendor-list-state.ts | 2 +- .../_components/use-vendor-roster-form.ts | 36 +- .../_components/vendor-create-modal.tsx | 33 +- .../_components/vendor-detail-drawer.tsx | 418 ++++++------------ .../_components/vendor-filter-options.tsx | 252 ----------- .../_components/vendor-filters-drawer.tsx | 239 +++++++++- .../_components/vendor-roster-form-fields.tsx | 101 ++--- .../_components/vendors-list-toolbar.tsx | 145 +++--- .../vendors/_components/vendors-table.tsx | 242 ++++------ src/app/(protected)/vendors/index.tsx | 125 +----- .../table/build-inline-draft-create-input.ts | 1 + .../list/table/cells/site-cell.tsx | 4 + .../list/table/cells/site-dialog-footer.tsx | 27 ++ .../table/cells/site-dialog-poc-fields.tsx | 48 +- .../table/cells/site-dialog-poc-section.tsx | 38 ++ .../list/table/cells/site-dialog.tsx | 64 +-- .../list/table/wo-table-inline-row-cells.tsx | 2 + .../list/table/wo-table-row-cells.tsx | 2 + .../_components/poc/poc-contact-fields.tsx | 165 +++++++ .../_components/wizard/wizard-sidebar.tsx | 13 + .../wizard/wizard-step-location-service.tsx | 57 +-- src/components/ui/page-header.tsx | 9 +- .../mappers/additional-contacts-mapper.ts | 91 ++++ .../assign-wizard-service-poc-fields.ts | 6 + .../mappers/expand-scalar-board-ops.ts | 7 + .../mappers/work-order-table-row-mapper.ts | 2 + .../types/work-order-additional-contact.ts | 11 + .../types/work-order-board-detail.ts | 2 + .../work-orders/types/work-order-board.ts | 6 +- .../work-orders/types/work-order-table-row.ts | 4 + .../work-orders/types/work-order-wizard.ts | 5 + .../vendors/use-vendor-roster-form.test.tsx | 45 +- .../vendors/vendor-detail-drawer.test.tsx | 58 +-- .../(protected)/vendors/vendors-list.test.tsx | 166 ++----- ...ork-orders-board-core-affordances.test.tsx | 1 + .../additional-contacts-mapper.test.ts | 67 +++ .../mappers/work-order-board-mapper.test.ts | 4 + .../work-order-board-patch-mapper.test.ts | 13 + .../mappers/work-order-mapper.test.ts | 10 + .../utils/slide-over-draft.test.ts | 1 + .../utils/table-filter-utils.test.ts | 1 + tmp/pr-descriptions/pr6-multi-poc.md | 37 ++ 44 files changed, 1268 insertions(+), 1413 deletions(-) delete mode 100644 src/app/(protected)/vendors/_components/vendor-filter-options.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section.tsx create mode 100644 src/app/(protected)/workorders/_components/poc/poc-contact-fields.tsx create mode 100644 src/domain/work-orders/mappers/additional-contacts-mapper.ts create mode 100644 src/domain/work-orders/types/work-order-additional-contact.ts create mode 100644 src/test/domain/work-orders/mappers/additional-contacts-mapper.test.ts create mode 100644 tmp/pr-descriptions/pr6-multi-poc.md diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e5c8bcf3..8bbfdfb8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,18 +306,11 @@ 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"); - 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("heading", { name: "Vendors / Technicians" })).toBeVisible(); + await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -330,58 +323,52 @@ 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 technicians")).toBeVisible(); - await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active vendor entries · 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 technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · 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 filters" }).click(); + await page.getByRole("button", { name: "Apply" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText("1 technician · filtered by")).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · 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 inactive technicians found")).toBeVisible(); + await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); - await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -391,35 +378,33 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - 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 page.getByRole("button", { name: "+ Add Vendor" }).click(); await expect( - page.getByRole("button", { name: "Add Vendor", exact: true }).last(), - ).toBeEnabled(); + page.getByText("Register a company and add zero, one, or multiple technicians."), + ).toBeVisible(); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( "dispatch@gateway.test", ); - 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("Address")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL")).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 (optional)").last().fill("New Technician"); + await page.getByLabel("Technician Name").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 (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); + 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); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -444,7 +429,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: "Adam Whyte" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -453,9 +438,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$/); - await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); - await detailDrawer.getByRole("button", { name: "Save changes" }).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.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -463,18 +448,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().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: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("switch", { name: "Active status" }).click(); - await detailDrawer.getByRole("button", { name: "Save changes" }).click(); + 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 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(); }); @@ -483,7 +468,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); + const addButton = page.getByRole("button", { name: "+ Add Vendor" }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -492,12 +477,10 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect( - page.getByRole("button", { name: "Add Vendor", exact: true }).last(), - ).toBeEnabled(); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone (optional)").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await page.getByLabel("Company Phone").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); @@ -523,8 +506,7 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -535,8 +517,7 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); - await page.getByRole("switch", { name: "Active status" }).click(); + await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -560,7 +541,6 @@ 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 b63291be..9b6f3b0d 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(onSuccess?: () => void): VendorDeactivationState { +export function useVendorDeactivation(): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -39,10 +39,7 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio if (!target || target.id == null) return; setError(null); deleteVendor.mutate(target.id, { - onSuccess: () => { - setTarget(null); - onSuccess?.(); - }, + onSuccess: () => setTarget(null), 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 fdc90685..9385a834 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(48); + const [pageSize, setPageSize] = useState(12); 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 6bf89b1f..f6ea637a 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,7 +2,6 @@ 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"; @@ -21,30 +20,11 @@ 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 { @@ -75,7 +55,6 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, - startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -90,16 +69,9 @@ 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: createDefaults, + defaultValues: emptyVendorCompanyRosterForm, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -124,10 +96,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(createDefaults); + reset(emptyVendorCompanyRosterForm); resetSelection(); clearConflict(); - }, [clearConflict, createDefaults, reset, resetSelection]); + }, [clearConflict, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; @@ -137,7 +109,7 @@ export function useVendorRosterForm({ save.mutate( { mode: isUpdate ? "update" : "create", - values: withoutBlankNewTechnicians(formValues), + values: 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 31804a3c..d0025662 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,11 +21,7 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ - mode: "create", - onSuccess: onClose, - startWithTechnician: true, - }); + const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); const { resetForm } = form; useEffect(() => { @@ -37,24 +33,17 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="sm" + maxWidth="md" aria-labelledby="vendor-create-dialog-title" - slotProps={{ - paper: { - role: "dialog", - sx: { maxHeight: "88vh", borderRadius: 2 }, - }, - }} + slotProps={{ paper: { role: "dialog" } }} > - - Add Vendor - - - Register a vendor company and its technicians. - + Add Vendor Company + + Register a company and add zero, one, or multiple technicians. + - + {form.loadErrorMessage !== null && ( @@ -84,9 +73,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving} + disabled={form.isSaving || !form.isValid} > - {form.isSaving ? : "Add Vendor"} + {form.isSaving ? : "Add Vendor Company"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 74d6ef02..ad7ffa29 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, useState, type ReactNode } from "react"; -import { Controller, useWatch } from "react-hook-form"; +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "react-router"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,22 +13,15 @@ import { Drawer, IconButton, Link, + List, + ListItem, Stack, - Switch, + Typography, } 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 = 420; +const DRAWER_WIDTH = 440; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -40,45 +33,106 @@ 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 ( @@ -87,18 +141,23 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 2.5, + px: 3, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} - - - {roster?.name || vendor?.companyName || "Vendor details"} - + + {roster?.name || vendor?.companyName || "Vendor company"} + + + {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} + @@ -108,19 +167,18 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const address = [roster.address, roster.city, roster.state, roster.zip] - .filter(Boolean) - .join(", "); - + const mapsUrl = roster.googleMapsUrl; return ( - - - - {Boolean(roster.googleMapsUrl) && ( + + + {Boolean(mapsUrl) && ( - - - - - Trade - - - {trades.length > 0 ? ( - trades.map((trade, index) => ( - - )) - ) : ( - — - )} - - - - - {technician.totalJobs ?? 0} - - Total Jobs - - - - - Status - - Technicians + {roster.technicians.length === 0 ? ( + + No technicians assigned to this company. + + ) : ( + + {roster.technicians.map((technician) => ( + - - - + ))} + + )} ); } -function DrawerBody({ - roster, - technician, -}: { - roster: VendorCompanyRoster; - technician?: VendorRosterTechnician; -}) { +function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { return ( - - - Company - - + + Company + - {technician ? ( - - ) : ( - - No technicians assigned to this company. - - )} + {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -218,8 +246,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -232,143 +260,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -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); +export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { + const navigate = useNavigate(); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -381,30 +274,25 @@ export function VendorDetailDrawer({ !hasVendorId ? (companyId ?? undefined) : undefined, ); - const selectedTechnician = resolveTechnician(roster, vendor); - - useEffect(() => { - if (open) setIsEditing(startInEdit); - }, [open, startInEdit, vendor]); + const editHref = hasVendorId + ? `/vendors/edit/${vendorId}` + : `/vendors/company/${companyId ?? ""}`; useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isEditing) onClose(); + if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [isEditing, onClose, open]); + }, [onClose, open]); return ( { - if (reason === "escapeKeyDown" && isEditing) return; - onClose(); - }} + onClose={onClose} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -416,21 +304,9 @@ export function VendorDetailDrawer({ }} > - + - {isEditing && vendor ? ( - setIsEditing(false)} - onRequestDeactivation={onRequestDeactivation} - /> - ) : isLoading ? ( + {isLoading ? ( @@ -440,14 +316,14 @@ export function VendorDetailDrawer({ ) : roster ? ( <> - - setIsEditing(true)} /> + + navigate(editHref)} /> ) : ( - + 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 deleted file mode 100644 index c9be7d45..00000000 --- a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx +++ /dev/null @@ -1,252 +0,0 @@ -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 e3b91cf5..ae4b51ba 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,9 +1,32 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; import CloseIcon from "@mui/icons-material/Close"; -import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; -import { VendorFilterOptions } from "./vendor-filter-options"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Box, + Button, + Checkbox, + Chip, + Drawer, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; -import type { VendorFacets } from "@/domain/vendors/types/vendor"; interface VendorFiltersDrawerProps { open: boolean; @@ -15,6 +38,32 @@ 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, @@ -25,12 +74,23 @@ 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(); @@ -73,7 +133,165 @@ 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 d6d5cbf7..32f91675 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -28,7 +28,6 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; - showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -55,7 +54,6 @@ function CompanyNameField({ )} @@ -122,7 +119,10 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - + + Company + + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message} + helperText={errors.companyPhone?.message ?? "Phone or email required"} fullWidth /> )} @@ -155,8 +155,7 @@ function CompanyFields({ render={({ field }) => ( ( - - )} + render={({ field }) => } /> - + ( )} @@ -214,14 +206,7 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -236,7 +221,6 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; - showStatus: boolean; } function TechnicianRow({ @@ -246,13 +230,12 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, - showStatus, }: TechnicianRowProps) { return ( - {index + 1} - TECHNICIAN + Technician {index + 1} @@ -273,8 +256,7 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -310,29 +292,26 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth - sx={{ display: "none" }} /> )} /> - {showStatus && ( - ( - field.onChange(checked)} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> - )} + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> ); } @@ -341,12 +320,10 @@ function TechniciansFieldArray({ control, errors, tradeOptions, - showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; - showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -389,7 +366,6 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} - showStatus={showStatus} /> )) )} @@ -399,17 +375,12 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; + const { control, errors, tradeOptions = [] } = 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 a8203ecb..e3e7cfa5 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,9 +1,13 @@ -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"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + Button, + Chip, + CircularProgress, + Stack, + TablePagination, + TextField, + Typography, +} from "@mui/material"; interface VendorsListToolbarProps { globalCount: number; @@ -29,44 +33,52 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - } - onClick={onOpenFilters} - aria-label="Open vendor filters" - badge={appliedFilterCount} - className="w-[88.609375px] text-[13px]" - > - Filters - - } - onClick={onAddVendor} - className="w-[115.546875px] border-0 px-3 text-[13px]" - > - Add Vendor - - {isFetching && !isLoading && ( - + + + + Vendors / Technicians + + + {globalCount} vendor entries · {activeCount} active + + {isFetching && !isLoading && } + + + + onSearchChange(e.target.value)} + placeholder="Search name, company, trade..." + className="min-w-[260px]" + /> + + + + ); } @@ -78,21 +90,16 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; - resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ - chips, - resultCount, - onClearAll, -}: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by - + + Applied: + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} + {totalCount === 1 ? "vendor entry" : "vendor entries"} {isFiltered && " · filtered"} - - - {activeCount} active · {inactiveCount} inactive - - {totalCount > pageSize && ( - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[24, 48, 96]} - /> - )} + {` · ${activeCount} active · ${inactiveCount} inactive`} + + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index db588cff..802fff8a 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,10 +1,11 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; +import { useNavigate } from "react-router"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Box, + Chip, CircularProgress, IconButton, Link, @@ -17,8 +18,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 = [ @@ -29,6 +30,9 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", + "Status", + "View", + "Edit", ] as const; function getInitials(name: string): string { @@ -50,34 +54,19 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } -function VendorStatus({ isActive }: { isActive: boolean }) { - return ( - - - {isActive ? "Active" : "Inactive"} - - ); -} - interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { +function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { + const navigate = useNavigate(); 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) => { @@ -99,62 +88,37 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) onKeyDown={handleKeyDown} > - - {row.companyName || "—"} - + {row.companyName || "—"} - + {getInitials(row.contactName)} - - {row.contactName || "—"} - + {row.contactName || "—"} - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && ( - - +{extraTrades} - - )} + {extraTrades > 0 && } )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -163,53 +127,71 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + + + - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - { event.stopPropagation(); - onOpenEdit(row); + navigate( + row.id != null + ? `/vendors/edit/${row.id}` + : `/vendors/company/${row.companyId ?? ""}`, + ); }} > + {hasVendorId && row.isActive && ( + + { + event.stopPropagation(); + onOpenDeactivation(row); + }} + > + + + + )} @@ -218,118 +200,50 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) interface VendorsTableProps { rows: VendorListItem[]; - isActive: boolean; - isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenEdit: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; } export function VendorsTable({ rows, - isActive, - isFiltered, isLoading, showError, onOpenDetail, - onOpenEdit, + onOpenDeactivation, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; - const columnCount = TABLE_COLUMNS.length + 2; return ( - - - - - - - - - - - - - + +
    {TABLE_COLUMNS.map((column) => ( - + {column} ))} - Status - {isLoading ? ( - + ) : showEmptyRow ? ( - - - - - {`No ${isActive ? "active" : "inactive"} technicians found`} - - - {isFiltered - ? "Try a different search, or add a new vendor." - : "Add a vendor to get started."} - - + + + No vendors match the current filters. + ) : ( @@ -338,7 +252,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenEdit={onOpenEdit} + onOpenDeactivation={onOpenDeactivation} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index 48447ad6..a084b59b 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,91 +23,16 @@ 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: `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.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); + filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); + filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); filters.jobBuckets.forEach((value) => - chips.push({ - category: "jobBuckets", - value, - label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, - }), + chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), ); return chips; } @@ -117,12 +42,7 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); - const closeDetail = () => { - setDetailTarget(null); - setDetailStartsInEdit(false); - }; - const deactivation = useVendorDeactivation(closeDetail); + const deactivation = useVendorDeactivation(); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -141,7 +61,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, @@ -179,18 +102,10 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={(row) => { - setDetailStartsInEdit(false); - setDetailTarget(row); - }} - onOpenEdit={(row) => { - setDetailStartsInEdit(true); - setDetailTarget(row); - }} + onOpenDetail={setDetailTarget} + onOpenDeactivation={deactivation.open} /> setDetailTarget(null)} /> void; @@ -29,6 +31,7 @@ export function SiteCell({ pocName, pocPhone, pocNotes, + additionalContacts, sites, q, onSave, @@ -82,6 +85,7 @@ export function SiteCell({ pocName={pocName} pocPhone={pocPhone} pocNotes={pocNotes} + additionalContacts={additionalContacts} sites={sites} onSave={onSave} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx new file mode 100644 index 00000000..6d7d927c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; +import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog"; + +type SiteDialogFooterProps = { + onClose: () => void; + onSave: (patch: SitePatch) => void; + buildPatch: () => SitePatch; +}; + +export function SiteDialogFooter({ onClose, onSave, buildPatch }: SiteDialogFooterProps) { + return ( +
    + + +
    + ); +} 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..66874d72 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 @@ -1,47 +1 @@ -import { - DLG_INPUT_CLS, - DLG_INPUT_STYLE, -} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; -import { Text } from "@/components/ui/text"; -import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; - -type SiteDialogPocFieldsProps = { - pocName: string; - pocPhone: string; - pocNotes: string; -}; - -export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) { - return ( -
    - Point of Contact - - Shown from site/work order records. Board edit saves site code only. - -
    - - -