diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 51aa4783..8bbfdfb8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -147,7 +147,7 @@ async function mockVendorApi( const filtered = source.filter( (vendor) => (!search || - `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}` + `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}` .toLowerCase() .includes(search)) && (!company || vendor.CompanyName === company), @@ -160,6 +160,75 @@ async function mockVendorApi( PageSize: Number(url.searchParams.get("pageSize") ?? 12), }); }); + await page.route(/\/api\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1]; + + if (request.method() === "POST") { + state.createdBody = request.postDataJSON(); + await fulfillJson(route, { + ...state.createdBody, + companyId: 999, + rowVersion: "rv-created", + }); + return; + } + + if (request.method() === "PUT" && pathCompanyId) { + state.updatedBody = request.postDataJSON(); + const technicians = Array.isArray(state.updatedBody.technicians) + ? (state.updatedBody.technicians as Array>) + : []; + for (const technician of technicians) { + const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id)); + if (vendor && typeof technician.isActive === "boolean") { + vendor.IsActive = technician.isActive; + } + } + await fulfillJson(route, { + ...state.updatedBody, + companyId: Number(pathCompanyId), + rowVersion: "rv-updated", + }); + return; + } + + const vendorId = url.searchParams.get("vendorId"); + const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId; + const anchor = vendorId + ? vendorRecords.find((vendor) => String(vendor.Id) === vendorId) + : vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId); + if (!anchor) { + await fulfillJson(route, { message: "Vendor roster not found" }, 404); + return; + } + await fulfillJson(route, { + companyId: anchor.CompanyId, + rowVersion: "rv-1", + name: anchor.CompanyName, + companyPhone: anchor.CompanyPhone, + email: anchor.Email, + address: anchor.Address, + city: anchor.City, + state: anchor.State, + zip: anchor.Zip, + googleMapsUrl: anchor.GoogleMapsUrl, + notes: anchor.Notes, + technicians: vendorRecords + .filter((vendor) => vendor.CompanyId === anchor.CompanyId) + .map((vendor) => ({ + id: vendor.Id, + contactName: vendor.ContactName, + phone: vendor.Phone, + email: vendor.Email, + preferredContact: vendor.PreferredContact ?? "Phone", + tradeSpecialties: vendor.TradeSpecialties, + isActive: vendor.IsActive, + totalJobs: vendor.TotalJobs, + })), + }); + }); await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => { const vendorId = Number(route.request().url().split("/").at(-2)); await fulfillJson(route, { @@ -181,6 +250,15 @@ async function mockVendorApi( }, }); }); + await page.route(/\/api\/vendors\/\d+\/portal-token$/, (route) => + fulfillJson(route, { + data: { + portalUrl: "https://vendor.example.test/token", + issuedAt: "2026-07-31T12:00:00Z", + expiresAt: "2026-08-07T12:00:00Z", + }, + }), + ); await page.route(/\/api\/vendors\/\d+$/, async (route) => { const id = route.request().url().split("/").pop() ?? ""; if (route.request().method() === "PUT") { @@ -232,7 +310,7 @@ test.describe("Vendor directory prototype parity", () => { await page.goto("/vendors"); await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); - await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); + await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -251,16 +329,19 @@ test.describe("Vendor directory prototype parity", () => { } await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible(); + await 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 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 expect(page.getByText("Metro HVAC")).toBeVisible(); + 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(); @@ -270,7 +351,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); await page.getByRole("button", { name: "Apply" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", @@ -291,46 +372,60 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); - test("creates with company autofill, then views and edits every detail field", async ({ + test("adds a technician to an existing company, then views and edits the aggregate", async ({ page, }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); await page.getByRole("button", { name: "+ Add Vendor" }).click(); - await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible(); + await expect( + 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" }).last()).toBeDisabled(); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); - await page.getByLabel("Technician Name").fill("New Technician"); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test"); + await expect(page.getByRole("textbox", { name: "Email", 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 page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing"); - await page.getByRole("button", { name: "Add trade" }).click(); - await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC"); - await page.getByRole("button", { name: "Add trade" }).click(); - await expect(page.getByRole("button", { name: "Move Plumbing up" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Move HVAC down" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Move HVAC up" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Move Plumbing down" })).toBeVisible(); - await page.getByRole("button", { name: "Move HVAC up" }).click(); + 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"); + 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 expect(page.getByText("Preferred Contact")).toHaveCount(0); - await page.getByRole("button", { name: "Add Vendor" }).last().click(); - await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0); - expect(state.createdBody).not.toHaveProperty("preferredContact"); - expect(state.createdBody).toMatchObject({ - companyName: "Gateway Plumbing", - companyId: 101, - contactName: "New Technician", + 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", - tradeSpecialties: "HVAC, Plumbing", notes: "Created in browser E2E", + rowVersion: "rv-1", }); + expect(state.updatedBody?.technicians).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + contactName: "New Technician", + tradeSpecialties: "Plumbing, HVAC", + }), + ]), + ); + const submittedTechnicians = Array.isArray(state.updatedBody?.technicians) + ? (state.updatedBody.technicians as Array>) + : []; + const newTechnician = submittedTechnicians.find( + (technician) => technician.contactName === "New Technician", + ); + expect(newTechnician?.preferredContact).toBeUndefined(); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); @@ -343,13 +438,10 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72"); - await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly"); - await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E"); - await detailDrawer.getByRole("switch", { name: "Active" }).click(); - await detailDrawer.getByRole("button", { name: "Save" }).click(); + await expect(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"); - expect(state.updatedBody?.isActive).toBe(false); }); test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { @@ -364,15 +456,11 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(detailDrawer.getByRole("button", { name: "Save" })).toBeDisabled(); - - await detailDrawer.getByRole("switch", { name: "Inactive" }).click(); - await detailDrawer.getByRole("button", { name: "Save" }).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 expect(detailDrawer.getByText("Active").first()).toBeVisible(); - - await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); - await page.getByRole("tab", { name: "Active (2)" }).click(); + await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -389,13 +477,27 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); - await page.getByLabel("Technician Name").fill("Independent Tech"); + await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByRole("button", { name: "Add Vendor" }).last().click(); + await page.getByLabel("Company Phone").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); - await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC"); - expect(state.createdBody?.companyId).toBeNull(); + await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); + expect(state.createdBody?.technicians).toEqual([]); + }); + + test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => { + await mockVendorApi(page); + await page.goto("/vendors"); + + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + const detailDrawer = page.locator(".MuiDrawer-paper").last(); + await expect(detailDrawer).toBeVisible(); + await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible(); + await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0); }); test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({ diff --git a/scripts/governance-check.mjs b/scripts/governance-check.mjs index 9e36d764..8cabc70f 100644 --- a/scripts/governance-check.mjs +++ b/scripts/governance-check.mjs @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -38,7 +38,9 @@ function gitLines(args) { function governedFiles() { const tracked = gitLines(["ls-files"]); const untracked = gitLines(["ls-files", "--others", "--exclude-standard"]); - return [...new Set([...tracked, ...untracked])].filter(isGoverned); + return [...new Set([...tracked, ...untracked])].filter( + (relativePath) => isGoverned(relativePath) && existsSync(path.join(ROOT, relativePath)), + ); } function lineCount(relativePath) { diff --git a/scripts/vendor-e2e-mock-server.mjs b/scripts/vendor-e2e-mock-server.mjs new file mode 100644 index 00000000..3c53457e --- /dev/null +++ b/scripts/vendor-e2e-mock-server.mjs @@ -0,0 +1,224 @@ +import http from "node:http"; + +const port = Number(process.env.VENDOR_MOCK_PORT ?? 5142); +let nextCompanyId = 103; +let nextTechnicianId = 3; +const companies = [ + { + companyId: 101, + rowVersion: "rv-101-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "https://maps.google.com/gateway", + notes: "Preferred after-hours vendor", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "adam@gateway.test", + preferredContact: "Email", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 72, + }, + ], + }, + { + companyId: 102, + rowVersion: "rv-102-1", + name: "Metro HVAC", + companyPhone: "(314) 555-0110", + email: "dispatch@metro.test", + address: "2 Pine St", + city: "Clayton", + state: "MO", + zip: "63105", + googleMapsUrl: "", + notes: "", + technicians: [], + }, +]; + +function json(response, body, status = 200) { + response.writeHead(status, { + "content-type": "application/json", + "access-control-allow-origin": "*", + }); + response.end(JSON.stringify(body)); +} + +async function readBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; +} + +function findCompany(url) { + const vendorId = url.searchParams.get("vendorId"); + const companyId = url.searchParams.get("companyId"); + if (vendorId) { + return companies.find((company) => + company.technicians.some((technician) => String(technician.id) === vendorId), + ); + } + return companies.find((company) => String(company.companyId) === companyId); +} + +function companyFacet(company) { + return { + companyId: company.companyId, + name: company.name, + companyPhone: company.companyPhone, + email: company.email, + address: company.address, + city: company.city, + state: company.state, + zip: company.zip, + googleMapsUrl: company.googleMapsUrl, + }; +} + +function directoryRows() { + return companies.flatMap((company) => + company.technicians.length + ? company.technicians.map((technician) => ({ + Id: technician.id, + CompanyId: company.companyId, + CompanyName: company.name, + ContactName: technician.contactName, + Email: technician.email, + Phone: technician.phone, + CompanyPhone: company.companyPhone, + City: company.city, + State: company.state, + TradeSpecialties: technician.tradeSpecialties, + GoogleMapsUrl: company.googleMapsUrl, + Notes: company.notes, + TotalJobs: technician.totalJobs, + IsActive: technician.isActive, + PreferredContact: technician.preferredContact, + })) + : [ + { + Id: null, + CompanyId: company.companyId, + CompanyName: company.name, + ContactName: "", + Email: company.email, + Phone: "", + CompanyPhone: company.companyPhone, + City: company.city, + State: company.state, + TradeSpecialties: "", + GoogleMapsUrl: company.googleMapsUrl, + Notes: company.notes, + TotalJobs: 0, + IsActive: true, + PreferredContact: "Phone", + }, + ], + ); +} + +const server = http.createServer(async (request, response) => { + const url = new URL(request.url ?? "/", `http://${request.headers.host}`); + if (request.method === "OPTIONS") return json(response, {}); + + if (url.pathname === "/api/Vendor/GetVendorList") { + const search = (url.searchParams.get("search") ?? "").toLowerCase(); + const active = url.searchParams.get("isActive") !== "false"; + const rows = directoryRows().filter( + (row) => + row.IsActive === active && + (!search || + `${row.CompanyName} ${row.ContactName} ${row.TradeSpecialties} ${row.City} ${row.State}` + .toLowerCase() + .includes(search)), + ); + return json(response, { Data: rows, TotalCount: rows.length, PageNumber: 1, PageSize: 12 }); + } + + if (url.pathname === "/api/Vendor/facets") { + return json(response, { + data: { + companies: companies.map(companyFacet), + trades: ["Plumbing", "HVAC"], + locations: companies.map((company) => ({ + city: company.city, + state: company.state, + label: `${company.city}, ${company.state}`, + })), + jobBuckets: [], + }, + }); + } + + if (url.pathname === "/api/vendor-company-roster" && request.method === "GET") { + const company = findCompany(url); + return company ? json(response, company) : json(response, { message: "Not found" }, 404); + } + + if (url.pathname === "/api/vendor-company-roster" && request.method === "POST") { + const body = await readBody(request); + const company = { + ...body, + companyId: nextCompanyId++, + rowVersion: `rv-${Date.now()}`, + technicians: (body.technicians ?? []).map((technician) => ({ + ...technician, + id: nextTechnicianId++, + totalJobs: 0, + })), + }; + companies.push(company); + return json(response, company); + } + + const rosterMatch = url.pathname.match(/^\/api\/vendor-company-roster\/(\d+)$/); + if (rosterMatch && request.method === "PUT") { + const company = companies.find((item) => item.companyId === Number(rosterMatch[1])); + if (!company) return json(response, { message: "Not found" }, 404); + const body = await readBody(request); + Object.assign(company, body, { + companyId: company.companyId, + rowVersion: `rv-${Date.now()}`, + technicians: (body.technicians ?? []).map((technician) => ({ + ...technician, + id: technician.id ?? nextTechnicianId++, + totalJobs: + company.technicians.find((existing) => existing.id === technician.id)?.totalJobs ?? 0, + })), + }); + return json(response, company); + } + + const portalMatch = url.pathname.match(/^\/api\/vendors\/(\d+)\/portal-token$/); + if (portalMatch) { + return json(response, { + data: { + portalUrl: "https://vendor.example.test/token", + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + }, + }); + } + + const impactMatch = url.pathname.match(/^\/api\/vendors\/(\d+)\/deactivation-impact$/); + if (impactMatch) { + return json(response, { + data: { vendorId: Number(impactMatch[1]), canDeactivate: true, openWorkOrders: [] }, + }); + } + + return json(response, { message: `No mock for ${request.method} ${url.pathname}` }, 404); +}); + +server.listen(port, "127.0.0.1", () => { + process.stdout.write(`Vendor E2E mock listening on http://127.0.0.1:${port}\n`); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index b43647d6..f43d1e07 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -126,6 +126,10 @@ export const API_PATHS = { facets: "Vendor/facets", deactivationImpact: "deactivation-impact", }, + vendorCompanyRoster: { + base: "vendor-company-roster", + byCompany: (companyId: string | number) => `vendor-company-roster/${companyId}`, + }, calendar: { getEventsForCalendar: "Calendar/GetEventsForCalendar", getEventList: "Calendar/GetEventList", diff --git a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts new file mode 100644 index 00000000..9b6f3b0d --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor"; +import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact"; +import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; + +export interface VendorDeactivationState { + target: VendorListItem | null; + error: string | null; + impact: VendorDeactivationImpact | undefined; + isLoading: boolean; + impactError: Error | null; + isSaving: boolean; + open: (row: VendorListItem) => void; + close: () => void; + confirm: () => void; +} + +export function useVendorDeactivation(): VendorDeactivationState { + const [target, setTarget] = useState(null); + const [error, setError] = useState(null); + const deleteVendor = useDeleteVendor(); + const { + data: impact, + isLoading, + error: impactError, + } = useVendorDeactivationImpact(target?.id ?? undefined); + + const open = (row: VendorListItem) => { + setTarget(row); + setError(null); + }; + + const close = () => { + setTarget(null); + setError(null); + }; + + const confirm = () => { + if (!target || target.id == null) return; + setError(null); + deleteVendor.mutate(target.id, { + onSuccess: () => setTarget(null), + onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), + }); + }; + + return { + target, + error, + impact, + isLoading, + impactError, + isSaving: deleteVendor.isPending, + open, + close, + confirm, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts b/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts deleted file mode 100644 index 5e13fad0..00000000 --- a/src/app/(protected)/vendors/_components/use-vendor-detail-drawer.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useForm, useWatch, type FieldErrors, type Resolver } from "react-hook-form"; -import type { ZodError } from "zod"; -import { - getChangedVendorValues, - vendorUpdateSchema, - type VendorFormValues, -} from "@/domain/vendors/schemas/vendor-schema"; -import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; -import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import type { Vendor, VendorFacetCompany } from "@/domain/vendors/types/vendor"; - -const DEFAULT_VALUES: VendorFormValues = { - companyName: "", - contactName: "", - email: "", - phone: "", - companyPhone: "", - googleMapsUrl: "", - notes: "", - totalJobs: 0, - address: "", - city: "", - state: "", - zip: "", - tradeSpecialties: "", - isActive: true, - companyId: null, -}; - -function toFormValues(vendor: Vendor): VendorFormValues { - return { - companyName: vendor.companyName ?? "", - contactName: vendor.contactName ?? "", - email: vendor.email ?? "", - phone: vendor.phone ?? "", - companyPhone: vendor.companyPhone ?? "", - googleMapsUrl: vendor.googleMapsUrl ?? "", - notes: vendor.notes ?? "", - totalJobs: vendor.totalJobs ?? 0, - address: vendor.address ?? "", - city: vendor.city ?? "", - state: vendor.state ?? "", - zip: vendor.zip ?? "", - tradeSpecialties: vendor.tradeSpecialties ?? "", - isActive: vendor.isActive, - companyId: vendor.companyId ?? null, - }; -} - -function buildFieldErrors(error: ZodError): FieldErrors { - return error.issues.reduce>((fieldErrors, issue) => { - const field = issue.path[0]; - if (typeof field === "string") { - fieldErrors[field as keyof VendorFormValues] = { - type: issue.code, - message: issue.message, - }; - } - return fieldErrors; - }, {}); -} - -interface UseVendorDrawerFormProps { - detail: Vendor | undefined; - open: boolean; - vendorId: string | number | undefined; - onModeChange: (mode: "view" | "edit") => void; -} - -export function useVendorDetailDrawerForm({ - detail, - open, - vendorId, - onModeChange, -}: UseVendorDrawerFormProps) { - const { data: facets } = useVendorFacets(); - const updateVendor = useUpdateVendor(); - const [submitError, setSubmitError] = useState(null); - const [original, setOriginal] = useState(null); - const originalRef = useRef(null); - const companies = useMemo(() => facets?.companies ?? [], [facets]); - - const resolver = useCallback>(async (values) => { - const result = vendorUpdateSchema.safeParse( - getChangedVendorValues(values, originalRef.current), - ); - return result.success - ? { values, errors: {} } - : { values: {}, errors: buildFieldErrors(result.error) }; - }, []); - - const form = useForm({ - resolver, - mode: "onChange", - defaultValues: DEFAULT_VALUES, - }); - const { control, reset, setValue, trigger } = form; - const currentValues = useWatch({ control }); - const changedValues = useMemo( - () => getChangedVendorValues(currentValues, original), - [currentValues, original], - ); - - useEffect(() => { - if (open && detail) { - const values = toFormValues(detail); - reset(values); - setOriginal(values); - originalRef.current = values; - setSubmitError(null); - } else if (!open) { - setOriginal(null); - originalRef.current = null; - setSubmitError(null); - } - }, [open, detail, reset]); - - const onCompanySelected = (company: VendorFacetCompany | null) => { - if (!company) return; - const options = { shouldDirty: true, shouldValidate: true } as const; - setValue("companyId", company.companyId, options); - setValue("companyPhone", company.companyPhone, options); - setValue("email", company.email, options); - setValue("address", company.address, options); - setValue("city", company.city, options); - setValue("state", company.state, options); - setValue("zip", company.zip, options); - setValue("googleMapsUrl", company.googleMapsUrl, options); - void trigger(); - }; - - const cancel = () => { - if (original) reset(original); - setSubmitError(null); - onModeChange("view"); - }; - - const submit = (values: VendorFormValues) => { - if (vendorId === undefined || vendorId === "") return; - const changed = getChangedVendorValues(values, original); - if (Object.keys(changed).length === 0) return; - setSubmitError(null); - updateVendor.mutate( - { id: vendorId, values: changed }, - { - onSuccess: () => onModeChange("view"), - onError: (error: Error) => setSubmitError(error.message || "Failed to save vendor"), - }, - ); - }; - - return { - ...form, - cancel, - companies, - hasChanges: Object.keys(changedValues).length > 0, - isFormValid: vendorUpdateSchema.safeParse(changedValues).success, - onCompanySelected, - submit, - submitError, - updateVendor, - }; -} diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts new file mode 100644 index 00000000..9385a834 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -0,0 +1,103 @@ +import { useMemo, useState } from "react"; +import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import { useDebounce } from "@/hooks/use-debounce"; + +export interface VendorListState { + search: string; + debouncedSearch: string; + page: number; + pageSize: number; + isActive: boolean; + appliedFilters: VendorFilters; + listParams: { + search: string; + page: number; + pageSize: number; + isActive: boolean; + companies: string[]; + trades: string[]; + locations: string[]; + jobBuckets: string[]; + }; + setSearchWithReset: (value: string) => void; + setPage: (page: number) => void; + setPageSizeWithReset: (size: number) => void; + setIsActiveWithReset: (isActive: boolean) => void; + setAppliedFiltersWithReset: (filters: VendorFilters) => void; + removeFilter: (category: keyof VendorFilters, value: string) => void; + clearFilters: () => void; +} + +export function useVendorListState(): VendorListState { + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [isActive, setIsActive] = useState(true); + const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); + + const debouncedSearch = useDebounce(search, 300); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + isActive, + companies: appliedFilters.companies, + trades: appliedFilters.trades, + locations: appliedFilters.locations, + jobBuckets: appliedFilters.jobBuckets, + }), + [debouncedSearch, page, pageSize, isActive, appliedFilters], + ); + + const setSearchWithReset = (value: string) => { + setSearch(value); + setPage(0); + }; + + const setPageSizeWithReset = (size: number) => { + setPageSize(size); + setPage(0); + }; + + const setIsActiveWithReset = (next: boolean) => { + setIsActive(next); + setPage(0); + }; + + const setAppliedFiltersWithReset = (filters: VendorFilters) => { + setAppliedFilters(filters); + setPage(0); + }; + + const removeFilter = (category: keyof VendorFilters, value: string) => { + setAppliedFilters((current) => ({ + ...current, + [category]: current[category].filter((item) => item !== value), + })); + setPage(0); + }; + + const clearFilters = () => { + setAppliedFilters(emptyVendorFilters); + setPage(0); + }; + + return { + search, + debouncedSearch, + page, + pageSize, + isActive, + appliedFilters, + listParams, + setSearchWithReset, + setPage, + setPageSizeWithReset, + setIsActiveWithReset, + setAppliedFiltersWithReset, + removeFilter, + clearFilters, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts new file mode 100644 index 00000000..f6ea637a --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -0,0 +1,148 @@ +import { useCallback, useMemo, useState } from "react"; +import { useForm, useWatch, type FieldErrors } from "react-hook-form"; +import { + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import { + getSingleStatusOnlyChange, + useSaveVendorCompanyRoster, +} from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; +import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection"; +import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; +import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; +import type { + VendorCompanyRoster, + VendorFacetCompany, + VendorRosterConflict, +} from "@/domain/vendors/types/vendor"; + +export interface VendorRosterFormProps { + mode: "create" | "update"; + vendorId?: string | number; + companyId?: string | number; + onSuccess?: (roster: VendorCompanyRoster) => void; +} + +export interface VendorRosterForm { + control: ReturnType>["control"]; + errors: FieldErrors; + handleSubmit: ReturnType>["handleSubmit"]; + isLoading: boolean; + isError: boolean; + error: Error | null; + isSaving: boolean; + isValid: boolean; + conflict: VendorRosterConflict | null; + loadErrorMessage: string | null; + submit: (values: VendorCompanyRosterFormValues) => void; + reload: () => void; + retryLoad: () => void; + roster: VendorCompanyRoster | undefined; + companies: VendorFacetCompany[]; + trades: string[]; + selectedCompanyId: string | number | null; + selectCompany: (company: VendorFacetCompany | null) => Promise; + clearSelectedCompany: (nextName?: string) => void; + resetForm: () => void; +} + +export function useVendorRosterForm({ + mode, + vendorId, + companyId, + onSuccess, +}: VendorRosterFormProps): VendorRosterForm { + const query = useVendorCompanyRoster( + mode === "update" ? vendorId : undefined, + mode === "update" ? companyId : undefined, + ); + const { data: facets } = useVendorFacets(); + const save = useSaveVendorCompanyRoster(); + const [conflict, setConflict] = useState(null); + + const routeRoster = mode === "update" ? query.data : undefined; + const companies = useMemo(() => facets?.companies ?? [], [facets]); + const trades = useMemo(() => facets?.trades ?? [], [facets]); + const resolver = useVendorRosterResolver(routeRoster); + + const form = useForm({ + resolver, + defaultValues: emptyVendorCompanyRosterForm, + values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, + mode: "onChange", + }); + const { control, handleSubmit, reset, formState } = form; + const watched = useWatch({ control }); + + const clearConflict = useCallback(() => setConflict(null), []); + const selection = useVendorRosterSelection({ + mode, + reset, + query, + clearConflict, + }); + const { resetSelection } = selection; + + const isValid = useMemo(() => { + if (vendorCompanyRosterSchema.safeParse(watched).success) return true; + return Boolean( + routeRoster && + getSingleStatusOnlyChange(routeRoster, watched as VendorCompanyRosterFormValues), + ); + }, [routeRoster, watched]); + + const resetForm = useCallback(() => { + reset(emptyVendorCompanyRosterForm); + resetSelection(); + clearConflict(); + }, [clearConflict, reset, resetSelection]); + + const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; + const isUpdate = mode === "update" || selection.selectedRoster != null; + + const submit = (formValues: VendorCompanyRosterFormValues) => { + setConflict(null); + save.mutate( + { + mode: isUpdate ? "update" : "create", + values: formValues, + companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, + rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, + originalRoster: mode === "update" ? routeRoster : undefined, + }, + { + onSuccess: (data) => onSuccess?.(data), + onError: (error) => { + if (isVendorRosterConflictError(error)) setConflict(error.conflict); + }, + }, + ); + }; + + return { + control, + errors: formState.errors, + handleSubmit, + isLoading: Boolean(query.isLoading), + isError: Boolean(query.isError), + error: query.error ?? null, + isSaving: save.isPending, + isValid, + conflict, + loadErrorMessage: selection.loadErrorMessage, + submit, + reload: selection.reload, + retryLoad: selection.retryLoad, + roster: routeRoster, + companies, + trades, + selectedCompanyId: selection.selectedCompanyId, + selectCompany: selection.selectCompany, + clearSelectedCompany: selection.clearSelectedCompany, + resetForm, + }; +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts new file mode 100644 index 00000000..12e1810a --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-resolver.ts @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { type Resolver } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; + +export function useVendorRosterResolver( + routeRoster: VendorCompanyRoster | undefined, +): Resolver { + const strictResolver = useMemo(() => zodResolver(vendorCompanyRosterSchema), []); + return useMemo>( + () => async (values, context, options) => { + if ( + routeRoster && + getSingleStatusOnlyChange(routeRoster, values as VendorCompanyRosterFormValues) + ) { + return { values, errors: {} }; + } + return strictResolver(values, context, options); + }, + [routeRoster, strictResolver], + ); +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts new file mode 100644 index 00000000..cce00e18 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -0,0 +1,172 @@ +import { useCallback, useRef, useState } from "react"; +import { useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { + emptyVendorCompanyRosterForm, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor"; +import type { UseFormReset } from "react-hook-form"; + +const SELECT_ERROR_MESSAGE = "Couldn't load that company's roster. Try again."; +const RELOAD_ERROR_MESSAGE = "Couldn't reload the latest company. Try again."; + +export interface VendorRosterSelection { + selectedRoster: VendorCompanyRoster | null; + loadErrorMessage: string | null; + selectedCompanyId: string | number | null; + selectCompany: (company: VendorFacetCompany | null) => Promise; + clearSelectedCompany: (nextName?: string) => void; + reload: () => void; + retryLoad: () => void; + resetSelection: () => void; +} + +interface UseVendorRosterSelectionOptions { + mode: "create" | "update"; + reset: UseFormReset; + query: UseQueryResult; + clearConflict: () => void; +} + +export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues { + return { + name: roster.name, + companyPhone: roster.companyPhone, + email: roster.email, + address: roster.address, + city: roster.city, + state: roster.state, + zip: roster.zip, + googleMapsUrl: roster.googleMapsUrl, + notes: roster.notes, + technicians: roster.technicians.map((technician) => ({ + id: technician.id, + contactName: technician.contactName, + phone: technician.phone, + email: technician.email, + preferredContact: technician.preferredContact, + tradeSpecialties: technician.tradeSpecialties, + isActive: technician.isActive, + })), + }; +} + +export function useVendorRosterSelection({ + mode, + reset, + query, + clearConflict, +}: UseVendorRosterSelectionOptions): VendorRosterSelection { + const queryClient = useQueryClient(); + const [selectedRoster, setSelectedRoster] = useState(null); + const [loadErrorMessage, setLoadErrorMessage] = useState(null); + const retryTargetRef = useRef< + { kind: "select"; company: VendorFacetCompany } | { kind: "reload" } | null + >(null); + const requestIdRef = useRef(0); + + const fetchRosterByCompany = useCallback( + async (id: string | number): Promise => + queryClient.fetchQuery({ + queryKey: queryKeys.vendors.roster("companyId", id), + queryFn: () => vendorCompanyRosterApi.get({ companyId: id }), + }), + [queryClient], + ); + + const selectCompany = useCallback( + async (company: VendorFacetCompany | null) => { + if (!company || company.companyId == null) { + requestIdRef.current += 1; + setSelectedRoster(null); + setLoadErrorMessage(null); + retryTargetRef.current = null; + return; + } + const requestId = (requestIdRef.current += 1); + setLoadErrorMessage(null); + retryTargetRef.current = { kind: "select", company }; + try { + const roster = await fetchRosterByCompany(company.companyId); + if (requestIdRef.current !== requestId) return; + reset(toFormValues(roster)); + setSelectedRoster(roster); + clearConflict(); + retryTargetRef.current = null; + } catch { + if (requestIdRef.current !== requestId) return; + setLoadErrorMessage(SELECT_ERROR_MESSAGE); + } + }, + [clearConflict, fetchRosterByCompany, reset], + ); + + const clearSelectedCompany = useCallback( + (nextName = "") => { + requestIdRef.current += 1; + if (selectedRoster) { + reset({ ...emptyVendorCompanyRosterForm, name: nextName }); + } + setSelectedRoster(null); + clearConflict(); + setLoadErrorMessage(null); + retryTargetRef.current = null; + }, + [clearConflict, reset, selectedRoster], + ); + + const reload = useCallback(() => { + const requestId = (requestIdRef.current += 1); + clearConflict(); + setLoadErrorMessage(null); + retryTargetRef.current = { kind: "reload" }; + if (mode === "update") { + void query.refetch().then((result) => { + if (requestIdRef.current !== requestId) return; + if (result.isError) setLoadErrorMessage(RELOAD_ERROR_MESSAGE); + else retryTargetRef.current = null; + }); + return; + } + if (selectedRoster?.companyId != null) { + void fetchRosterByCompany(selectedRoster.companyId) + .then((roster) => { + if (requestIdRef.current !== requestId) return; + reset(toFormValues(roster)); + setSelectedRoster(roster); + retryTargetRef.current = null; + }) + .catch(() => { + if (requestIdRef.current !== requestId) return; + setLoadErrorMessage(RELOAD_ERROR_MESSAGE); + }); + } + }, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]); + + const retryLoad = useCallback(() => { + const target = retryTargetRef.current; + if (!target) return; + if (target.kind === "select") void selectCompany(target.company); + else void reload(); + }, [reload, selectCompany]); + + const resetSelection = useCallback(() => { + requestIdRef.current += 1; + setSelectedRoster(null); + setLoadErrorMessage(null); + retryTargetRef.current = null; + }, []); + + return { + selectedRoster, + loadErrorMessage, + selectedCompanyId: selectedRoster?.companyId ?? null, + selectCompany, + clearSelectedCompany, + reload, + retryLoad, + resetSelection, + }; +} diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index fdcd9a1c..d0025662 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -1,8 +1,5 @@ -import { useEffect, useState } from "react"; -import { useForm, useWatch } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; import { - Alert, Box, Button, CircularProgress, @@ -13,140 +10,72 @@ import { Stack, Typography, } from "@mui/material"; -import { VendorFormFields } from "./vendor-form-fields"; -import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; -import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; +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"; interface VendorCreateModalProps { open: boolean; onClose: () => void; - tradeOptions: string[]; } -const defaultValues: VendorFormValues = { - companyName: "", - contactName: "", - email: "", - phone: "", - companyPhone: "", - googleMapsUrl: "", - notes: "", - totalJobs: 0, - address: "", - city: "", - state: "", - zip: "", - tradeSpecialties: "", - isActive: true, - companyId: null, -}; - -export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) { - const { data: facets, isLoading: isLoadingFacets, error: facetsError } = useVendorFacets(); - const createVendor = useCreateVendor(); - const [submitError, setSubmitError] = useState(null); - - const { - control, - handleSubmit, - reset, - setValue, - trigger, - formState: { errors }, - } = useForm({ - resolver: zodResolver(vendorSchema), - defaultValues, - mode: "onChange", - }); - const currentValues = useWatch({ control }); - const isFormValid = vendorSchema.safeParse(currentValues).success; +export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { + const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const { resetForm } = form; useEffect(() => { - if (open) { - reset(defaultValues); - setSubmitError(null); - } - }, [open, reset]); - - const onCompanySelected = (company: VendorFacetCompany | null) => { - if (!company) return; - const options = { shouldDirty: true, shouldValidate: true } as const; - setValue("companyId", company.companyId, options); - setValue("companyPhone", company.companyPhone, options); - setValue("email", company.email, options); - setValue("address", company.address, options); - setValue("city", company.city, options); - setValue("state", company.state, options); - setValue("zip", company.zip, options); - setValue("googleMapsUrl", company.googleMapsUrl, options); - void trigger(); - }; - - const onSubmit = (values: VendorFormValues) => { - setSubmitError(null); - createVendor.mutate(values, { - onSuccess: () => { - onClose(); - }, - onError: (error: Error) => { - setSubmitError(error.message || "Failed to create vendor"); - }, - }); - }; - - const companies = facets?.companies ?? []; + if (!open) resetForm(); + }, [open, resetForm]); return ( - Add Vendor + Add Vendor Company - Register a technician and their vendor company. + Register a company and add zero, one, or multiple technicians. - {Boolean(facetsError) && ( - - Company autocomplete unavailable. You can still type a company manually. - + {form.loadErrorMessage !== null && ( + )} - {Boolean(submitError) && {submitError}} - {isLoadingFacets ? ( - - ) : ( - - - + {form.conflict !== null && ( + )} + + + - diff --git a/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx new file mode 100644 index 00000000..cf673b6c --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-deactivation-dialog.tsx @@ -0,0 +1,126 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + Stack, + Typography, +} from "@mui/material"; +import type { VendorDeactivationImpact, VendorListItem } from "@/domain/vendors/types/vendor"; + +interface VendorDeactivationDialogProps { + target: VendorListItem | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; + impact: VendorDeactivationImpact | undefined; + impactError: Error | null; + onClose: () => void; + onConfirm: () => void; +} + +export function VendorDeactivationDialog({ + target, + isLoading, + isSaving, + error, + impact, + impactError, + onClose, + onConfirm, +}: VendorDeactivationDialogProps) { + const hasBlockingImpact = Boolean(impact && !impact.canDeactivate); + + return ( + + Deactivate Vendor + + + + Deactivate "{target?.companyName}"? Existing work-order and audit history will + be preserved. + + + {isLoading && ( + + + + Checking linked work orders... + + + )} + + {Boolean(impactError) && !isLoading && ( + + Could not verify linked work orders. Close and try again. + + )} + + {impact != null && !isLoading && !impact.canDeactivate && ( + + This vendor cannot be deactivated because it still has open work orders. + + )} + + {impact != null && impact.openWorkOrders.length > 0 && ( + + + Open work orders ({impact.openWorkOrders.length}) + + + {impact.openWorkOrders.map((wo) => ( + + {wo.scheduledDate} + + ) : undefined + } + > + + + {wo.workOrderNumber + ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` + : wo.workOrderTitle || `Work order ${wo.workOrderId}`} + + {Boolean(wo.status) && ( + + {wo.status} + + )} + + + ))} + + + )} + + {Boolean(error) && {error}} + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx deleted file mode 100644 index 8aa47f56..00000000 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer-content.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import type { FormEventHandler, ReactNode } from "react"; -import { Controller, type Control, type FieldErrors } 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"; -import { - Alert, - Box, - Button, - Chip, - FormControlLabel, - IconButton, - Link, - Stack, - Switch, - Tooltip, - Typography, -} from "@mui/material"; -import { VendorFormFields } from "./vendor-form-fields"; -import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { Vendor, VendorFacetCompany, VendorListItem } from "@/domain/vendors/types/vendor"; - -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "—"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - -interface HeaderProps { - vendor: VendorListItem | null; - mode: "view" | "edit"; - pending: boolean; - onClose: () => void; -} - -export function VendorDrawerHeader({ vendor, mode, pending, onClose }: HeaderProps) { - return ( - `1px solid ${theme.palette.divider}`, - }} - > - - - {getInitials(vendor?.contactName ?? "")} - - - - {vendor?.companyName || "Vendor"} - - - {mode === "edit" ? "Editing vendor" : "Vendor details"} - - - - - - - - ); -} - -interface ViewProps { - detail: Vendor; - mapsUrl: string | undefined; - submitError: string | null; - trades: string[]; -} - -export function VendorDrawerView({ detail, mapsUrl, submitError, trades }: ViewProps) { - return ( - - {Boolean(submitError) && {submitError}} - - - {Boolean(trades[0]) && ( - 1 ? `${trades[0]} +${trades.length - 1}` : trades[0]} - /> - )} - - - - - - - - - - - - - {Boolean(mapsUrl) && ( - - - Open in Google Maps - - )} - - - - - - {Boolean(detail.notes) && ( - - - {detail.notes} - - - )} - - ); -} - -function TradesSection({ trades }: { trades: string[] }) { - return ( - - {trades.length === 0 ? ( - - No trades specified. - - ) : ( - - {trades.map((trade, index) => ( - - ))} - - )} - - ); -} - -interface EditProps { - control: Control; - errors: FieldErrors; - companies: VendorFacetCompany[]; - tradeOptions: string[]; - submitError: string | null; - onCompanySelected: (company: VendorFacetCompany | null) => void; - onSubmit: FormEventHandler; -} - -export function VendorDrawerEdit({ - control, - errors, - companies, - tradeOptions, - submitError, - onCompanySelected, - onSubmit, -}: EditProps) { - return ( - - - {Boolean(submitError) && {submitError}} - ( - - } - label={field.value ? "Active" : "Inactive"} - sx={{ alignSelf: "flex-start" }} - /> - )} - /> - - - - ); -} - -interface ActionsProps { - mode: "view" | "edit"; - mapsUrl: string | undefined; - pending: boolean; - canSave: boolean; - onCancel: () => void; - onEdit: () => void; -} - -export function VendorDrawerActions({ - mode, - mapsUrl, - pending, - canSave, - onCancel, - onEdit, -}: ActionsProps) { - return ( - `1px solid ${theme.palette.divider}`, - }} - > - {mode === "view" ? ( - <> - {Boolean(mapsUrl) && ( - - - - )} - - - - ) : ( - <> - - - - )} - - ); -} - -function DetailSection({ title, children }: { title: string; children: ReactNode }) { - return ( - - - {title} - - {children} - - ); -} - -function DetailField({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; - return ( - - - {label} - - {display} - - ); -} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 4ade2647..ad7ffa29 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,15 +1,27 @@ -import { Alert, Box, CircularProgress, Drawer, Stack, Typography } from "@mui/material"; +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"; import { - VendorDrawerActions, - VendorDrawerEdit, - VendorDrawerHeader, - VendorDrawerView, -} from "./vendor-detail-drawer-content"; -import { useVendorDetailDrawerForm } from "./use-vendor-detail-drawer"; -import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; -import type { VendorListItem } from "@/domain/vendors/types/vendor"; + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + Drawer, + IconButton, + Link, + List, + ListItem, + Stack, + Typography, +} from "@mui/material"; +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 ?? "") @@ -21,35 +33,266 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; - mode: "view" | "edit"; onClose: () => void; - onModeChange: (mode: "view" | "edit") => void; - tradeOptions: string[]; } -export function VendorDetailDrawer({ - open, +function SectionTitle({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Field({ label, value }: { label: string; value: string }) { + const display = value && value.trim() !== "" ? value : "—"; + return ( + + + {label} + + {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, - mode, onClose, - onModeChange, - tradeOptions, -}: VendorDetailDrawerProps) { +}: { + roster?: VendorCompanyRoster; + vendor: VendorListItem | null; + onClose: () => void; +}) { + return ( + `1px solid ${theme.palette.divider}`, + }} + > + + + {roster?.name || vendor?.companyName || "Vendor company"} + + + {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} + + + + + + + ); +} + +function CompanySection({ roster }: { roster: VendorCompanyRoster }) { + const mapsUrl = roster.googleMapsUrl; + return ( + + + + + {Boolean(mapsUrl) && ( + + + Open in Google Maps + + )} + + ); +} + +function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) { + return ( + + Technicians + {roster.technicians.length === 0 ? ( + + No technicians assigned to this company. + + ) : ( + + {roster.technicians.map((technician) => ( + + ))} + + )} + + ); +} + +function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { + return ( + + Company + + + + {Boolean(roster.notes) && ( + <> + + + Notes + + {roster.notes} + + + + )} + + ); +} + +function DrawerActions({ onEdit }: { onEdit: () => void }) { + return ( + `1px solid ${theme.palette.divider}`, + alignItems: "center", + justifyContent: "flex-end", + }} + > + + + ); +} + +export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { + const navigate = useNavigate(); const vendorId = vendor?.id; + const companyId = vendor?.companyId; + const hasVendorId = vendorId != null; const { - data: detail, + data: roster, isLoading, error, - } = useVendorDetail(vendorId !== undefined && vendorId !== "" ? String(vendorId) : undefined); - const drawerForm = useVendorDetailDrawerForm({ detail, open, vendorId, onModeChange }); - const trades = splitTrades(detail?.tradeSpecialties ?? vendor?.tradeSpecialties); - const mapsUrl = detail?.googleMapsUrl; - const pending = drawerForm.updateVendor.isPending; + } = useVendorCompanyRoster( + hasVendorId ? vendorId : undefined, + !hasVendorId ? (companyId ?? undefined) : undefined, + ); + + const editHref = hasVendorId + ? `/vendors/edit/${vendorId}` + : `/vendors/company/${companyId ?? ""}`; + + useEffect(() => { + if (!open) return; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [onClose, open]); return ( - + + {isLoading ? ( ) : error ? ( - {error instanceof Error ? error.message : "Failed to load vendor"} + Failed to load vendor company. Try again. - ) : detail ? ( + ) : roster ? ( <> - {mode === "view" ? ( - - ) : ( - - )} - onModeChange("edit")} - /> + + navigate(editHref)} /> ) : ( diff --git a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-form-fields.tsx deleted file mode 100644 index f8fee0f4..00000000 --- a/src/app/(protected)/vendors/_components/vendor-form-fields.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { useState } from "react"; -import { Controller, type Control, type FieldErrors } from "react-hook-form"; -import AddIcon from "@mui/icons-material/Add"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import CloseIcon from "@mui/icons-material/Close"; -import { - Autocomplete, - Box, - Button, - Chip, - Divider, - FormLabel, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; -import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; - -interface VendorFormFieldsProps { - control: Control; - errors: FieldErrors; - companies: VendorFacetCompany[]; - tradeOptions: string[]; - onCompanySelected: (company: VendorFacetCompany | null) => void; - showTotalJobs?: boolean; -} - -type VendorFieldErrors = FieldErrors; - -function splitTrades(value: string | undefined): string[] { - return (value ?? "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); -} - -function joinTrades(trades: string[]): string { - return trades.join(", "); -} - -function formatPhoneMask(input: string): string { - const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10); - if (digits.length === 0) return ""; - if (digits.length < 4) return `(${digits}`; - if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; -} - -function TechnicianFields({ - control, - errors, -}: { - control: Control; - errors: VendorFieldErrors; -}) { - return ( - - - Technician - - - ( - - )} - /> - ( - field.onChange(formatPhoneMask(event.target.value))} - error={Boolean(errors.phone)} - helperText={errors.phone?.message} - fullWidth - /> - )} - /> - - - ); -} - -function CompanyFields({ - control, - errors, - companies, - onCompanySelected, -}: { - control: Control; - errors: VendorFieldErrors; - companies: VendorFacetCompany[]; - onCompanySelected: (company: VendorFacetCompany | null) => void; -}) { - return ( - - - Company - - - ( - ( - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={field.value ?? ""} - inputValue={field.value ?? ""} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - field.onChange(value); - companyIdField.onChange(null); - } - }} - onChange={(_event, value, reason) => { - if (typeof value === "string") { - field.onChange(value); - companyIdField.onChange(null); - return; - } - if (value) { - field.onChange(value.name); - companyIdField.onChange(value.companyId); - if (reason === "selectOption") { - onCompanySelected(value); - } - } else { - field.onChange(""); - companyIdField.onChange(null); - } - }} - renderInput={(params) => ( - - )} - /> - )} - /> - )} - /> - ( - field.onChange(formatPhoneMask(event.target.value))} - error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message} - fullWidth - /> - )} - /> - ( - - )} - /> - } - /> - - } - /> - ( - - )} - /> - } - /> - - ( - - )} - /> - - - ); -} - -function TradeSpecialtiesField({ - control, - tradeOptions, -}: { - control: Control; - tradeOptions: string[]; -}) { - const [tradeInput, setTradeInput] = useState(""); - - return ( - - Trade Specialties - - First trade is primary. Reorder with the arrows. - - { - const trades = splitTrades(field.value); - const remove = (trade: string) => { - field.onChange(joinTrades(trades.filter((entry) => entry !== trade))); - }; - const move = (index: number, direction: -1 | 1) => { - const next = [...trades]; - const target = index + direction; - if (target < 0 || target >= next.length) return; - [next[index], next[target]] = [next[target], next[index]]; - field.onChange(joinTrades(next)); - }; - const add = (trade: string) => { - const normalized = trade.trim(); - if (!normalized || trades.includes(normalized)) { - setTradeInput(""); - return; - } - field.onChange(joinTrades([...trades, normalized])); - setTradeInput(""); - }; - - return ( - - - {trades.length === 0 ? ( - - No trades selected. - - ) : ( - trades.map((trade, index) => ( - remove(trade)} - deleteIcon={} - sx={{ px: 0.5 }} - /> - )) - )} - - - { - if (reason === "input") setTradeInput(value); - }} - onChange={(_event, value) => { - if (typeof value === "string") add(value); - }} - renderInput={(params) => ( - { - if (event.key === "Enter") { - event.preventDefault(); - add(tradeInput); - } - }} - /> - )} - className="flex-1" - /> - - - {trades.length > 1 && ( - - {trades.map((trade, index) => ( - - {index > 0 && ( - - )} - {index < trades.length - 1 && ( - - )} - - ))} - - )} - - ); - }} - /> - - ); -} - -export function VendorFormFields({ - control, - errors, - companies, - tradeOptions, - onCompanySelected, - showTotalJobs = false, -}: VendorFormFieldsProps) { - return ( - - - - - - - - {showTotalJobs && ( - ( - - )} - /> - )} - ( - - )} - /> - - ); -} diff --git a/src/app/(protected)/vendors/_components/vendor-form.tsx b/src/app/(protected)/vendors/_components/vendor-form.tsx deleted file mode 100644 index 5bd61a14..00000000 --- a/src/app/(protected)/vendors/_components/vendor-form.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm, useWatch } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useParams } from "react-router"; -import { - Alert, - Box, - Button, - CircularProgress, - FormControlLabel, - Paper, - Stack, - Switch, - Typography, -} from "@mui/material"; -import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; -import { VendorFormFields } from "./vendor-form-fields"; -import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; -import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor"; -import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail"; -import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; - -const defaultValues: VendorFormValues = { - companyName: "", - contactName: "", - email: "", - phone: "", - companyPhone: "", - googleMapsUrl: "", - notes: "", - totalJobs: 0, - address: "", - city: "", - state: "", - zip: "", - tradeSpecialties: "", - isActive: true, - companyId: null, -}; - -export default function VendorFormPage() { - const { id } = useParams(); - const navigate = useNavigate(); - const isEdit = Boolean(id); - - const { data: vendor, isLoading, error } = useVendorDetail(id); - const { data: facets } = useVendorFacets(); - const { data: trades = [] } = useDropdownOptionsByCategory("Trade"); - const createVendor = useCreateVendor(); - const updateVendor = useUpdateVendor(); - - const { - handleSubmit, - reset, - setValue, - control, - trigger, - formState: { errors }, - } = useForm({ - resolver: zodResolver(vendorSchema), - defaultValues, - mode: "onChange", - }); - const currentValues = useWatch({ control }); - const isFormValid = vendorSchema.safeParse(currentValues).success; - - useEffect(() => { - if (vendor && isEdit) { - reset({ - companyName: vendor.companyName, - contactName: vendor.contactName, - email: vendor.email, - phone: vendor.phone, - companyPhone: vendor.companyPhone, - googleMapsUrl: vendor.googleMapsUrl, - notes: vendor.notes, - totalJobs: vendor.totalJobs, - address: vendor.address, - city: vendor.city, - state: vendor.state, - zip: vendor.zip, - tradeSpecialties: vendor.tradeSpecialties, - isActive: vendor.isActive, - companyId: vendor.companyId ?? null, - }); - } - }, [vendor, isEdit, reset]); - - const onCompanySelected = (company: VendorFacetCompany | null) => { - if (!company) return; - const options = { shouldDirty: true, shouldValidate: true } as const; - setValue("companyId", company.companyId, options); - setValue("companyPhone", company.companyPhone, options); - setValue("email", company.email, options); - setValue("address", company.address, options); - setValue("city", company.city, options); - setValue("state", company.state, options); - setValue("zip", company.zip, options); - setValue("googleMapsUrl", company.googleMapsUrl, options); - void trigger(); - }; - - const isSaving = createVendor.isPending || updateVendor.isPending; - - const onSubmit = (values: VendorFormValues) => { - if (isEdit && id) { - updateVendor.mutate({ id, values }, { onSuccess: () => navigate("/vendors") }); - return; - } - createVendor.mutate(values, { onSuccess: () => navigate("/vendors") }); - }; - - if (isEdit && isLoading) { - return ( - - - - ); - } - - return ( - - - - {isEdit ? "Edit Vendor" : "Create Vendor"} - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load vendor"} - - )} - - - - ( - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> - - trade.value)} - onCompanySelected={onCompanySelected} - showTotalJobs={isEdit} - /> - - {isEdit && id != null && } - - - - - - - - - - - ); -} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx b/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx new file mode 100644 index 00000000..5a1ff44e --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx @@ -0,0 +1,58 @@ +import { Alert, AlertTitle, Box, Button, List, ListItem, Stack, Typography } from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import type { VendorRosterConflict } from "@/domain/vendors/types/vendor"; + +interface VendorRosterConflictAlertProps { + conflict: VendorRosterConflict; + onReload: () => void; +} + +export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) { + if (conflict.kind === "stale") { + return ( + } + action={ + + } + > + Someone else updated this company + {conflict.message} + + ); + } + + return ( + + Open work orders block this save + {conflict.message} + {conflict.blockedWorkOrders.length > 0 && ( + + + {conflict.blockedWorkOrders.map((workOrder) => ( + + + + {workOrder.workOrderNumber + ? `${workOrder.workOrderNumber} — ${workOrder.workOrderTitle || "Untitled"}` + : workOrder.workOrderTitle || `Work order ${workOrder.workOrderId}`} + + {Boolean(workOrder.status) && ( + + {workOrder.status} + {workOrder.scheduledDate ? ` · ${workOrder.scheduledDate}` : ""} + + )} + + + ))} + + Resolve these work orders, then save again. + + )} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx new file mode 100644 index 00000000..32f91675 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -0,0 +1,386 @@ +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 { + Autocomplete, + Box, + Button, + Divider, + FormControlLabel, + IconButton, + Stack, + Switch, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { VendorTradeSpecialtiesField } from "./vendor-trade-specialties-field"; +import { formatNorthAmericanPhone } from "@/lib/format/na-phone"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; + +interface VendorRosterFormFieldsProps { + control: Control; + errors: FieldErrors; + companies?: VendorFacetCompany[]; + tradeOptions?: string[]; + selectedCompanyId?: string | number | null; + onSelectCompany?: (company: VendorFacetCompany | null) => Promise; + onClearSelectedCompany?: (nextName?: string) => void; +} + +function CompanyNameField({ + control, + errors, + companies, + selectedCompanyId, + onSelectCompany, + onClearSelectedCompany, +}: VendorRosterFormFieldsProps) { + const useAutocomplete = companies !== undefined && onSelectCompany !== undefined; + const selectedCompany = + companies?.find( + (company) => + company.companyId != null && String(company.companyId) === String(selectedCompanyId), + ) ?? null; + + if (!useAutocomplete) { + return ( + ( + + )} + /> + ); + } + + return ( + ( + + freeSolo + options={companies ?? []} + value={selectedCompany ?? field.value} + inputValue={field.value} + getOptionLabel={(option) => (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof value !== "string" && + option.companyId != null && + String(option.companyId) === String(value.companyId) + } + onChange={(_event, value) => { + if (typeof value === "object" && value !== null) { + void onSelectCompany?.(value); + return; + } + onClearSelectedCompany?.(typeof value === "string" ? value : ""); + field.onChange(typeof value === "string" ? value : ""); + }} + onInputChange={(_event, value, reason) => { + if (reason !== "input") return; + if (selectedCompanyId != null) onClearSelectedCompany?.(value); + field.onChange(value); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} + +function CompanyFields({ + control, + errors, + companies, + selectedCompanyId, + onSelectCompany, + onClearSelectedCompany, +}: VendorRosterFormFieldsProps) { + return ( + + + Company + + + + + ( + field.onChange(formatNorthAmericanPhone(event.target.value))} + error={Boolean(errors.companyPhone)} + helperText={errors.companyPhone?.message ?? "Phone or email required"} + fullWidth + /> + )} + /> + ( + + )} + /> + + } + /> + + } + /> + ( + + )} + /> + } + /> + + ( + + )} + /> + ( + + )} + /> + + + ); +} + +interface TechnicianRowProps { + control: Control; + index: number; + error: FieldErrors | undefined; + onRemove: () => void; + canRemove: boolean; + tradeOptions: string[]; +} + +function TechnicianRow({ + control, + index, + error, + onRemove, + canRemove, + tradeOptions, +}: TechnicianRowProps) { + return ( + + + + Technician {index + 1} + + + + + + + + + + ( + + )} + /> + + ( + field.onChange(formatNorthAmericanPhone(event.target.value))} + error={Boolean(error?.phone)} + helperText={error?.phone?.message} + fullWidth + /> + )} + /> + ( + + )} + /> + + + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + + ); +} + +function TechniciansFieldArray({ + control, + errors, + tradeOptions, +}: { + control: Control; + errors: FieldErrors; + tradeOptions: string[]; +}) { + const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); + + return ( + + + + Technicians ({fields.length}) + + + + + {fields.length === 0 ? ( + + No technicians yet. A company can be saved with zero technicians. + + ) : ( + fields.map((field, index) => ( + remove(index)} + canRemove + tradeOptions={tradeOptions} + /> + )) + )} + + + ); +} + +export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { + const { control, errors, tradeOptions = [] } = props; + return ( + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx b/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx new file mode 100644 index 00000000..0f9fb3d4 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-load-error.tsx @@ -0,0 +1,21 @@ +import { Alert, Button } from "@mui/material"; + +interface VendorRosterLoadErrorAlertProps { + message: string; + onRetry: () => void; +} + +export function VendorRosterLoadErrorAlert({ message, onRetry }: VendorRosterLoadErrorAlertProps) { + return ( + + Try again + + } + > + {message} + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx new file mode 100644 index 00000000..15f8ff9a --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -0,0 +1,130 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router"; +import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; +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 { useVendorRosterForm } from "./use-vendor-roster-form"; + +interface VendorRosterPageProps { + vendorId?: string; + companyId?: string; +} + +function PageShell({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: ReactNode; +}) { + return ( + + + + + + {title} + + + {subtitle} + + + {children} + + + + ); +} + +export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { + const navigate = useNavigate(); + const isEdit = vendorId !== undefined || companyId !== undefined; + const form = useVendorRosterForm({ + mode: isEdit ? "update" : "create", + vendorId, + companyId, + onSuccess: () => navigate("/vendors"), + }); + + if (isEdit && form.isLoading) { + return ( + + + + ); + } + + if (isEdit && form.isError) { + return ( + + Failed to load vendor company. Try again. + + + + + ); + } + + if (isEdit && !form.roster) { + return null; + } + + const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company"; + const subtitle = isEdit + ? "Update company details and technicians in one save." + : "Register a company and add zero, one, or multiple technicians."; + const companySelectionProps = isEdit + ? {} + : { + companies: form.companies, + selectedCompanyId: form.selectedCompanyId, + onSelectCompany: form.selectCompany, + onClearSelectedCompany: form.clearSelectedCompany, + }; + + return ( + + + + {form.loadErrorMessage !== null && ( + + )} + + {form.conflict !== null && ( + + )} + + + + {isEdit && vendorId !== undefined && vendorId !== "" && ( + + )} + + + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx new file mode 100644 index 00000000..ff63ef4c --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { Controller, type Control } from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import CloseIcon from "@mui/icons-material/Close"; +import { + Autocomplete, + Box, + Button, + Chip, + FormLabel, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +interface VendorTradeSpecialtiesFieldProps { + control: Control; + index: number; + tradeOptions: string[]; +} + +export function VendorTradeSpecialtiesField({ + control, + index, + tradeOptions, +}: VendorTradeSpecialtiesFieldProps) { + const [tradeInput, setTradeInput] = useState(""); + + return ( + + Trade Specialties + + First trade is primary. Reorder with the arrows. + + { + const trades = splitTrades(field.value); + const commit = (next: string[]) => field.onChange(next.join(", ")); + const add = (value: string) => { + const normalized = value.trim(); + if (normalized && !trades.includes(normalized)) commit([...trades, normalized]); + setTradeInput(""); + }; + const move = (tradeIndex: number, direction: -1 | 1) => { + const target = tradeIndex + direction; + if (target < 0 || target >= trades.length) return; + const next = [...trades]; + [next[tradeIndex], next[target]] = [next[target], next[tradeIndex]]; + commit(next); + }; + + return ( + + + {trades.length === 0 ? ( + + No trades selected. + + ) : ( + trades.map((trade, tradeIndex) => ( + + commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) + } + deleteIcon={} + /> + )) + )} + + + { + if (reason === "input") setTradeInput(value); + }} + onChange={(_event, value) => { + if (typeof value === "string") add(value); + }} + renderInput={(params) => ( + { + if (event.key === "Enter") { + event.preventDefault(); + add(tradeInput); + } + }} + /> + )} + className="flex-1" + /> + + + {trades.length > 1 && ( + + {trades.map((trade, tradeIndex) => ( + + {tradeIndex > 0 && ( + + )} + {tradeIndex < trades.length - 1 && ( + + )} + + ))} + + )} + + ); + }} + /> + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx new file mode 100644 index 00000000..e3e7cfa5 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -0,0 +1,168 @@ +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + Button, + Chip, + CircularProgress, + Stack, + TablePagination, + TextField, + Typography, +} from "@mui/material"; + +interface VendorsListToolbarProps { + globalCount: number; + activeCount: number; + isFetching: boolean; + isLoading: boolean; + search: string; + onSearchChange: (value: string) => void; + appliedFilterCount: number; + onOpenFilters: () => void; + onAddVendor: () => void; +} + +export function VendorsListToolbar({ + globalCount, + activeCount, + isFetching, + isLoading, + search, + onSearchChange, + appliedFilterCount, + onOpenFilters, + 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]" + /> + + + + + ); +} + +interface AppliedChipEntry { + key: string; + label: string; + onRemove: () => void; +} + +interface VendorAppliedFiltersBarProps { + chips: AppliedChipEntry[]; + onClearAll: () => void; +} + +export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { + if (chips.length === 0) return null; + return ( + + + Applied: + + {chips.map((chip) => ( + + ))} + + + ); +} + +interface VendorsListFooterProps { + totalCount: number; + isActive: boolean; + activeCount: number; + inactiveCount: number; + isFiltered: boolean; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +export function VendorsListFooter({ + totalCount, + isActive, + activeCount, + inactiveCount, + isFiltered, + page, + pageSize, + onPageChange, + onPageSizeChange, +}: VendorsListFooterProps) { + return ( + + + {totalCount} {isActive ? "active" : "inactive"}{" "} + {totalCount === 1 ? "vendor entry" : "vendor entries"} + {isFiltered && " · filtered"} + {` · ${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 new file mode 100644 index 00000000..802fff8a --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -0,0 +1,263 @@ +import { type KeyboardEvent, type MouseEvent } from "react"; +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, + Chip, + CircularProgress, + IconButton, + Link, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; + +const TABLE_COLUMNS = [ + "Company", + "Technician", + "Location", + "Trade", + "Phone", + "Email", + "Total Jobs", + "Status", + "View", + "Edit", +] as const; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; + return (first + last).toUpperCase(); +} + +function splitTrades(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((trade) => trade.trim()) + .filter(Boolean); +} + +function stopPropagation(event: MouseEvent): void { + event.stopPropagation(); +} + +interface VendorTableRowProps { + row: VendorListItem; + onOpenDetail: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; +} + +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) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + onOpenDetail(row); + }; + + return ( + onOpenDetail(row)} + onKeyDown={handleKeyDown} + > + + {row.companyName || "—"} + + + + + {getInitials(row.contactName)} + + {row.contactName || "—"} + + + {locationText} + + {primaryTrade === "—" ? ( + "—" + ) : ( + + {primaryTrade} + {extraTrades > 0 && } + + )} + + + {row.phone ? ( + + {row.phone} + + ) : ( + "—" + )} + + + {row.email ? ( + + {row.email} + + ) : ( + "—" + )} + + {row.totalJobs ?? 0} + + + + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + + + + + + { + event.stopPropagation(); + navigate( + row.id != null + ? `/vendors/edit/${row.id}` + : `/vendors/company/${row.companyId ?? ""}`, + ); + }} + > + + + + {hasVendorId && row.isActive && ( + + { + event.stopPropagation(); + onOpenDeactivation(row); + }} + > + + + + )} + + + + ); +} + +interface VendorsTableProps { + rows: VendorListItem[]; + isLoading: boolean; + showError: boolean; + onOpenDetail: (row: VendorListItem) => void; + onOpenDeactivation: (row: VendorListItem) => void; +} + +export function VendorsTable({ + rows, + isLoading, + showError, + onOpenDetail, + onOpenDeactivation, +}: VendorsTableProps) { + const showEmptyRow = !isLoading && !showError && rows.length === 0; + + return ( + + + + + {TABLE_COLUMNS.map((column) => ( + + {column} + + ))} + + + + {isLoading ? ( + + + + + + ) : showEmptyRow ? ( + + + + No vendors match the current filters. + + + + ) : ( + rows.map((row) => ( + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/vendors/company/[companyId].tsx b/src/app/(protected)/vendors/company/[companyId].tsx new file mode 100644 index 00000000..f2f356f6 --- /dev/null +++ b/src/app/(protected)/vendors/company/[companyId].tsx @@ -0,0 +1,7 @@ +import { useParams } from "react-router"; +import VendorRosterPage from "../_components/vendor-roster-page"; + +export default function EditVendorCompanyByCompanyPage() { + const { companyId } = useParams(); + return ; +} diff --git a/src/app/(protected)/vendors/edit/[id].tsx b/src/app/(protected)/vendors/edit/[id].tsx index 43e82d49..3fc0f3e7 100644 --- a/src/app/(protected)/vendors/edit/[id].tsx +++ b/src/app/(protected)/vendors/edit/[id].tsx @@ -1 +1,7 @@ -export { default } from "../_components/vendor-form"; +import { useParams } from "react-router"; +import VendorRosterPage from "../_components/vendor-roster-page"; + +export default function EditVendorCompanyPage() { + const { id } = useParams(); + return ; +} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index e7e0d746..a084b59b 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -1,78 +1,21 @@ -import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; -import { - Alert, - Avatar, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - Link, - List, - ListItem, - Paper, - Stack, - Tab, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, - Tabs, - TextField, - Tooltip, - Typography, -} from "@mui/material"; +import { useMemo, useState } from "react"; +import { Alert, Box, Tab, Tabs } from "@mui/material"; import { VendorCreateModal } from "./_components/vendor-create-modal"; import { VendorDetailDrawer } from "./_components/vendor-detail-drawer"; +import { VendorDeactivationDialog } from "./_components/vendor-deactivation-dialog"; import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer"; -import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters"; +import { + VendorAppliedFiltersBar, + VendorsListFooter, + VendorsListToolbar, +} from "./_components/vendors-list-toolbar"; +import { VendorsTable } from "./_components/vendors-table"; +import { useVendorDeactivation } from "./_components/use-vendor-deactivation"; +import { useVendorListState } from "./_components/use-vendor-list-state"; +import type { VendorFilters } from "./_components/vendor-filters"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; -import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor"; -import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; -import { useDebounce } from "@/hooks/use-debounce"; - -const TABLE_COLUMNS = [ - "Company", - "Technician", - "Location", - "Trade", - "Phone", - "Email", - "Total Jobs", - "Status", - "View", - "Edit", -] as const; - -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - const first = parts[0]?.[0] ?? ""; - const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; - return (first + last).toUpperCase(); -} - -function splitTrades(value: string | undefined): string[] { - return (value ?? "") - .split(",") - .map((trade) => trade.trim()) - .filter(Boolean); -} interface AppliedChip { category: keyof VendorFilters; @@ -95,210 +38,61 @@ function describeChips( } export default function VendorsListPage() { - const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); - const [isActive, setIsActive] = useState(true); - const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); + const filters = useVendorListState(); const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleteError, setDeleteError] = useState(null); const [detailTarget, setDetailTarget] = useState(null); - const [detailMode, setDetailMode] = useState<"view" | "edit">("view"); + const deactivation = useVendorDeactivation(); - const debouncedSearch = useDebounce(search, 300); - const deleteVendor = useDeleteVendor(); - const { - data: deactivationImpact, - isLoading: isLoadingImpact, - error: impactError, - } = useVendorDeactivationImpact(deleteTarget?.id); - const { data: facets } = useVendorFacets(isActive); - const { data: trades = [] } = useDropdownOptionsByCategory("Trade"); - const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]); - - const listParams = useMemo( - () => ({ - search: debouncedSearch, - page: page + 1, - pageSize, - isActive, - companies: appliedFilters.companies, - trades: appliedFilters.trades, - locations: appliedFilters.locations, - jobBuckets: appliedFilters.jobBuckets, - }), - [debouncedSearch, page, pageSize, isActive, appliedFilters], - ); - - const { data, isLoading, isFetching, error } = useVendorsList(listParams); + const { data: facets } = useVendorFacets(filters.isActive); + const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); const { data: activeCountData } = useVendorsList({ isActive: true, page: 1, pageSize: 1 }); const { data: inactiveCountData } = useVendorsList({ isActive: false, page: 1, pageSize: 1 }); + const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; const activeCount = activeCountData?.totalCount ?? 0; const inactiveCount = inactiveCountData?.totalCount ?? 0; - const globalCount = activeCount + inactiveCount; const jobBucketLabels = useMemo( () => new Map((facets?.jobBuckets ?? []).map((bucket) => [bucket.id, bucket.label])), [facets], ); - const appliedChips = useMemo( - () => describeChips(appliedFilters, jobBucketLabels), - [appliedFilters, jobBucketLabels], - ); - - const handleApplyFilters = (next: VendorFilters) => { - setAppliedFilters(next); - setPage(0); - }; - - const handleClearFilters = () => { - setAppliedFilters(emptyVendorFilters); - setPage(0); - }; - - const handleRemoveChip = (chip: AppliedChip) => { - setAppliedFilters((current) => ({ - ...current, - [chip.category]: current[chip.category].filter((value) => value !== chip.value), - })); - setPage(0); - }; - - const openDetail = (row: VendorListItem, mode: "view" | "edit") => { - setDetailTarget(row); - setDetailMode(mode); - }; - - const closeDetail = () => { - setDetailTarget(null); - setDetailMode("view"); - }; - - const openDeactivation = (row: VendorListItem) => { - setDeleteTarget(row); - setDeleteError(null); - }; - - const closeDeactivation = () => { - setDeleteTarget(null); - setDeleteError(null); - }; - - const handleDeleteConfirm = () => { - if (!deleteTarget) return; - setDeleteError(null); - deleteVendor.mutate(deleteTarget.id, { - onSuccess: () => { - setDeleteTarget(null); - }, - onError: (error: Error) => { - setDeleteError(error.message || "Failed to deactivate vendor"); - }, - }); - }; - - const stopAnd = (handler: () => void) => (event: MouseEvent) => { - event.stopPropagation(); - handler(); - }; - - const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - event.stopPropagation(); - openDetail(row, "view"); - }; - - const showLoadingRow = isLoading; - const showEmptyRow = !isLoading && !error && rows.length === 0; + const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - - - - Vendors / Technicians - - - {globalCount} technicians · {activeCount} active - - {isFetching && !isLoading && } - - - - { - setSearch(e.target.value); - setPage(0); - }} - placeholder="Search name, company, trade..." - className="min-w-[260px]" - /> - - - - + setFiltersOpen(true)} + onAddVendor={() => setCreateOpen(true)} + /> { - setIsActive(value === "active"); - setPage(0); - }} + value={filters.isActive ? "active" : "inactive"} + onChange={(_event, value: "active" | "inactive") => + filters.setIsActiveWithReset(value === "active") + } aria-label="Vendor status" > - {appliedChips.length > 0 && ( - - - Applied: - - {appliedChips.map((chip) => ( - handleRemoveChip(chip)} - aria-label={`Remove filter ${chip.label}`} - /> - ))} - - - )} + ({ + key: `${chip.category}-${chip.value}`, + label: chip.label, + onRemove: () => filters.removeFilter(chip.category, chip.value), + }))} + onClearAll={filters.clearFilters} + /> {Boolean(error) && ( @@ -306,307 +100,52 @@ export default function VendorsListPage() { )} - - - - - {TABLE_COLUMNS.map((column) => ( - - {column} - - ))} - - - - {showLoadingRow ? ( - - - - - - ) : showEmptyRow ? ( - - - - No vendors match the current filters. - - - - ) : ( - rows.map((row) => { - const trades = splitTrades(row.tradeSpecialties); - const primaryTrade = trades[0] ?? "—"; - const extraTrades = trades.length > 1 ? trades.length - 1 : 0; - const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - return ( - openDetail(row, "view")} - onKeyDown={handleRowKeyDown(row)} - > - - {row.companyName || "—"} - - - - - {getInitials(row.contactName)} - - {row.contactName || "—"} - - - {locationText} - - {primaryTrade === "—" ? ( - "—" - ) : ( - - {primaryTrade} - {extraTrades > 0 && } - - )} - - - {row.phone ? ( - event.stopPropagation()} - > - {row.phone} - - ) : ( - "—" - )} - - - {row.email ? ( - event.stopPropagation()} - > - {row.email} - - ) : ( - "—" - )} - - {row.totalJobs ?? 0} - - - - - - openDetail(row, "view"))} - > - - - - - - - - openDetail(row, "edit"))} - > - - - - {row.isActive && ( - - openDeactivation(row))} - > - - - - )} - - - - ); - }) - )} - -
-
- - - - {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "technician" : "technicians"} - {(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - setPage(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => { - setPageSize(Number(e.target.value)); - setPage(0); - }} - rowsPerPageOptions={[12, 24, 48]} - /> - - - setCreateOpen(false)} - tradeOptions={tradeOptions} + + 0 || Boolean(filters.debouncedSearch)} + page={filters.page} + pageSize={filters.pageSize} + onPageChange={filters.setPage} + onPageSizeChange={filters.setPageSizeWithReset} + /> + + setCreateOpen(false)} /> + setFiltersOpen(false)} facets={facets} - appliedFilters={appliedFilters} - onApply={handleApplyFilters} + appliedFilters={filters.appliedFilters} + onApply={filters.setAppliedFiltersWithReset} /> setDetailTarget(null)} /> - - Deactivate Vendor - - - - Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit - history will be preserved. - - - {isLoadingImpact && ( - - - - Checking linked work orders... - - - )} - - {Boolean(impactError) && !isLoadingImpact && ( - - Could not verify linked work orders. Close and try again. - - )} - - {deactivationImpact != null && - !isLoadingImpact && - !deactivationImpact.canDeactivate && ( - - This vendor cannot be deactivated because it still has open work orders. - - )} - - {deactivationImpact != null && deactivationImpact.openWorkOrders.length > 0 && ( - - - Open work orders ({deactivationImpact.openWorkOrders.length}) - - - {deactivationImpact.openWorkOrders.map((wo) => ( - - {wo.scheduledDate} - - ) : undefined - } - > - - - {wo.workOrderNumber - ? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}` - : wo.workOrderTitle || `Work order ${wo.workOrderId}`} - - {Boolean(wo.status) && ( - - {wo.status} - - )} - - - ))} - - - )} - - {Boolean(deleteError) && {deleteError}} - - - - - - - +
); } diff --git a/src/app/(protected)/vendors/new.tsx b/src/app/(protected)/vendors/new.tsx index 49113d8e..ea698cb7 100644 --- a/src/app/(protected)/vendors/new.tsx +++ b/src/app/(protected)/vendors/new.tsx @@ -1 +1,5 @@ -export { default } from "./_components/vendor-form"; +import VendorRosterPage from "./_components/vendor-roster-page"; + +export default function NewVendorCompanyPage() { + return ; +} diff --git a/src/domain/vendors/api/vendor-company-roster-api.ts b/src/domain/vendors/api/vendor-company-roster-api.ts new file mode 100644 index 00000000..dd9c978a --- /dev/null +++ b/src/domain/vendors/api/vendor-company-roster-api.ts @@ -0,0 +1,100 @@ +import { isHTTPError } from "ky"; +import { API_PATHS } from "@/api/api-paths"; +import { apiGet, apiPost, apiPut } from "@/api/api"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { + mapRosterConflict, + mapVendorCompanyRoster, + mapVendorRosterToBackend, +} from "@/domain/vendors/mappers/vendor-roster-mapper"; +import { VendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; + +const STALE_MESSAGE = + "This company was changed by another session. Reload the latest version and try again."; +const OPEN_WORK_ORDER_MESSAGE = + "This company cannot be saved because it still has open work orders."; + +async function readConflictBody(error: unknown): Promise { + if (!isHTTPError(error)) return undefined; + try { + return await error.response.json(); + } catch { + try { + const text = await error.response.text(); + return text ? JSON.parse(text) : undefined; + } catch { + return undefined; + } + } +} + +export async function throwRosterConflict(error: unknown): Promise { + const status = isHTTPError(error) ? error.response.status : 0; + const body = await readConflictBody(error); + const message = status === 409 ? STALE_MESSAGE : "Failed to save vendor company"; + const conflict = mapRosterConflict(body, message); + if (conflict.kind === "open-work-orders" && conflict.blockedWorkOrders.length === 0) { + conflict.message = OPEN_WORK_ORDER_MESSAGE; + } + throw new VendorRosterConflictError(conflict, status || 409); +} + +export interface RosterQuery { + vendorId?: string | number; + companyId?: string | number; +} + +function buildRosterSearchParams(query: RosterQuery): Record { + const params: Record = {}; + if (query.vendorId !== undefined && query.vendorId !== "") { + params.vendorId = String(query.vendorId); + } + if (query.companyId !== undefined && query.companyId !== "") { + params.companyId = String(query.companyId); + } + return params; +} + +export const vendorCompanyRosterApi = { + get: async (query: RosterQuery): Promise => { + const data = await apiGet(API_PATHS.vendorCompanyRoster.base, { + searchParams: buildRosterSearchParams(query), + }); + return mapVendorCompanyRoster(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + try { + const data = await apiPost( + API_PATHS.vendorCompanyRoster.base, + mapVendorRosterToBackend(payload), + ); + return mapVendorCompanyRoster(handleApiResponse(data)); + } catch (error) { + if (isHTTPError(error) && error.response.status === 409) { + await throwRosterConflict(error); + } + throw error; + } + }, + + update: async ( + companyId: string | number, + payload: unknown, + rowVersion: string, + ): Promise => { + try { + const data = await apiPut( + API_PATHS.vendorCompanyRoster.byCompany(companyId), + mapVendorRosterToBackend({ ...(payload as Record), rowVersion }), + ); + return mapVendorCompanyRoster(handleApiResponse(data)); + } catch (error) { + if (isHTTPError(error) && error.response.status === 409) { + await throwRosterConflict(error); + } + throw error; + } + }, +}; diff --git a/src/domain/vendors/lib/vendor-roster-conflict.ts b/src/domain/vendors/lib/vendor-roster-conflict.ts new file mode 100644 index 00000000..5095d7c5 --- /dev/null +++ b/src/domain/vendors/lib/vendor-roster-conflict.ts @@ -0,0 +1,17 @@ +import type { VendorRosterConflict } from "@/domain/vendors/types/vendor"; + +export class VendorRosterConflictError extends Error { + readonly status: number; + readonly conflict: VendorRosterConflict; + + constructor(conflict: VendorRosterConflict, status = 409) { + super(conflict.message); + this.name = "VendorRosterConflictError"; + this.status = status; + this.conflict = conflict; + } +} + +export function isVendorRosterConflictError(error: unknown): error is VendorRosterConflictError { + return error instanceof VendorRosterConflictError; +} diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 55130818..33a31223 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -38,10 +38,11 @@ function readNumber(record: Record, ...keys: string[]): number return 0; } -function readId(record: Record): string | number { +function readId(record: Record): string | number | null { const value = record.id ?? record.Id; - if (typeof value === "string" || typeof value === "number") return value; - return ""; + if (typeof value === "string") return value.trim() === "" ? null : value; + if (typeof value === "number" && Number.isFinite(value)) return value; + return null; } function readCompanyId(record: Record): string | number | null { diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts new file mode 100644 index 00000000..8dd450c4 --- /dev/null +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -0,0 +1,156 @@ +import type { + VendorCompanyRoster, + VendorPreferredContact, + VendorRosterBlockedWorkOrder, + VendorRosterConflict, + VendorRosterConflictKind, + VendorRosterTechnician, +} from "@/domain/vendors/types/vendor"; +import { toCanonicalPhone } from "@/domain/vendors/mappers/vendor-mapper"; + +const PREFERRED_CONTACT_VALUES = new Set(["Phone", "Email", "Text"]); + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + } + return ""; +} + +function readBool(record: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + } + return true; +} + +function readPreferredContact(record: Record): VendorPreferredContact | undefined { + const raw = record.preferredContact ?? record.PreferredContact; + if (typeof raw === "string") { + const normalized = raw.trim(); + if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) { + return normalized as VendorPreferredContact; + } + const lowered = normalized.toLowerCase(); + if (lowered === "phone" || lowered === "call") return "Phone"; + if (lowered === "email" || lowered === "e-mail") return "Email"; + if (lowered === "text" || lowered === "sms") return "Text"; + } + return undefined; +} + +function readId(record: Record, ...keys: string[]): string | number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim() !== "") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return undefined; +} + +function readCompanyId(record: Record): string | number | null { + const value = readId(record, "companyId", "CompanyId"); + return value ?? null; +} + +export function mapRosterTechnician(raw: unknown): VendorRosterTechnician { + const item = asRecord(raw); + return { + id: readId(item, "id", "Id"), + contactName: readString(item, "contactName", "ContactName"), + phone: readString(item, "phone", "Phone"), + email: readString(item, "email", "Email"), + preferredContact: readPreferredContact(item), + tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), + isActive: readBool(item, "isActive", "IsActive"), + totalJobs: Number(item.totalJobs ?? item.TotalJobs ?? 0), + }; +} + +export function mapRosterTechnicians(raw: unknown): VendorRosterTechnician[] { + return Array.isArray(raw) ? raw.map(mapRosterTechnician) : []; +} + +export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster { + const item = asRecord(raw); + return { + companyId: readCompanyId(item), + rowVersion: readString(item, "rowVersion", "RowVersion"), + name: readString(item, "name", "Name", "companyName", "CompanyName"), + companyPhone: readString(item, "companyPhone", "CompanyPhone"), + email: readString(item, "email", "Email"), + address: readString(item, "address", "Address"), + city: readString(item, "city", "City"), + state: readString(item, "state", "State"), + zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), + googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), + notes: readString(item, "notes", "Notes"), + technicians: mapRosterTechnicians(item.technicians ?? item.Technicians), + }; +} + +export function mapRosterTechnicianToBackend(technician: unknown): Record { + const item = asRecord(technician); + const payload: Record = { + contactName: readString(item, "contactName", "ContactName"), + phone: toCanonicalPhone(item.phone ?? item.Phone), + email: readString(item, "email", "Email"), + tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), + isActive: readBool(item, "isActive", "IsActive"), + }; + const preferredContact = readPreferredContact(item); + if (preferredContact) payload.preferredContact = preferredContact; + const id = readId(item, "id", "Id"); + if (id !== undefined) payload.id = id; + return payload; +} + +export function mapVendorRosterToBackend(values: unknown): Record { + const item = asRecord(values); + const techniciansRaw = item.technicians ?? item.Technicians; + const payload: Record = { + name: readString(item, "name", "Name", "companyName", "CompanyName"), + companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone), + email: readString(item, "email", "Email"), + address: readString(item, "address", "Address"), + city: readString(item, "city", "City"), + state: readString(item, "state", "State"), + zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"), + googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), + notes: readString(item, "notes", "Notes"), + technicians: Array.isArray(techniciansRaw) + ? techniciansRaw.map(mapRosterTechnicianToBackend) + : [], + }; + const rowVersion = readString(item, "rowVersion", "RowVersion"); + if (rowVersion) payload.rowVersion = rowVersion; + return payload; +} + +function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder { + const item = asRecord(raw); + return { + workOrderId: readId(item, "workOrderId", "WorkOrderId", "id", "Id") ?? "", + workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"), + workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle", "title", "Title"), + status: readString(item, "status", "Status"), + scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined, + }; +} + +export function mapRosterConflict(raw: unknown, fallbackMessage: string): VendorRosterConflict { + const item = asRecord(raw); + const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders; + const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : []; + const kind: VendorRosterConflictKind = + blockedWorkOrders.length > 0 ? "open-work-orders" : "stale"; + const message = readString(item, "message", "Message") || fallbackMessage; + return { kind, message, blockedWorkOrders }; +} diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts new file mode 100644 index 00000000..a429d69e --- /dev/null +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { + VENDOR_PREFERRED_CONTACT_VALUES, + type VendorPreferredContact, +} from "@/domain/vendors/types/vendor"; + +const preferredContactEnum = z.enum( + VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]], +); + +export function isAbsoluteHttpsUrl(value: string): boolean { + if (value.trim() === "") return true; + try { + const parsed = new URL(value); + return parsed.protocol === "https:"; + } catch { + return false; + } +} + +const httpsUrl = z + .string() + .trim() + .refine(isAbsoluteHttpsUrl, "Google Maps URL must be a valid HTTPS link"); + +export function isValidNorthAmericanPhone(value: string): boolean { + if (value === "") return true; + const digits = value.replace(/\D+/g, ""); + return digits.length === 10 || (digits.length === 11 && digits.startsWith("1")); +} + +const northAmericanPhone = z + .string() + .refine(isValidNorthAmericanPhone, "Enter a 10-digit phone number"); + +const optionalEmail = z.union([z.string().email("Invalid email"), z.literal("")]); + +export const rosterTechnicianSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + contactName: z.string(), + phone: northAmericanPhone, + email: optionalEmail, + preferredContact: preferredContactEnum.optional(), + tradeSpecialties: z.string(), + isActive: z.boolean(), +}); + +export type RosterTechnicianValues = z.infer; + +const baseCompanyFields = { + name: z.string().trim().min(1, "Company is required"), + companyPhone: northAmericanPhone, + email: optionalEmail, + address: z.string(), + city: z.string(), + state: z.string(), + zip: z.string(), + googleMapsUrl: httpsUrl, + notes: z.string(), + technicians: z.array(rosterTechnicianSchema), +}; + +export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => { + const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim()); + const hasEmail = Boolean(data.email && data.email.trim()); + if (!hasPhone && !hasEmail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["companyPhone"], + message: "Company phone or email is required", + }); + } +}); + +export type VendorCompanyRosterFormValues = z.infer; + +export const vendorCompanyRosterUpdateSchema = vendorCompanyRosterSchema.and( + z.object({ + companyId: z.union([z.string(), z.number()]).nullable(), + rowVersion: z.string().min(1, "Row version is required"), + }), +); + +export type VendorCompanyRosterUpdateValues = z.infer; + +export const emptyRosterTechnician: RosterTechnicianValues = { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, +}; + +export const emptyVendorCompanyRosterForm: VendorCompanyRosterFormValues = { + name: "", + companyPhone: "", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [], +}; diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index 6c67847c..d4140afa 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -3,7 +3,7 @@ export type VendorPreferredContact = "Phone" | "Email" | "Text"; export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"]; export interface VendorListItem { - id: string | number; + id: string | number | null; companyId: string | number | null; companyName: string; contactName: string; @@ -109,3 +109,45 @@ export interface VendorDeactivationImpact { canDeactivate: boolean; openWorkOrders: VendorOpenWorkOrderSummary[]; } + +export interface VendorRosterTechnician { + id?: string | number; + contactName: string; + phone: string; + email: string; + preferredContact?: VendorPreferredContact; + tradeSpecialties: string; + isActive: boolean; + totalJobs: number; +} + +export interface VendorCompanyRoster { + companyId: string | number | null; + rowVersion: string; + name: string; + companyPhone: string; + email: string; + address: string; + city: string; + state: string; + zip: string; + googleMapsUrl: string; + notes: string; + technicians: VendorRosterTechnician[]; +} + +export interface VendorRosterBlockedWorkOrder { + workOrderId: string | number; + workOrderNumber: string; + workOrderTitle: string; + status: string; + scheduledDate?: string; +} + +export type VendorRosterConflictKind = "open-work-orders" | "stale"; + +export interface VendorRosterConflict { + kind: VendorRosterConflictKind; + message: string; + blockedWorkOrders: VendorRosterBlockedWorkOrder[]; +} diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts new file mode 100644 index 00000000..37532220 --- /dev/null +++ b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts @@ -0,0 +1,131 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import type { + VendorCompanyRosterFormValues, + VendorCompanyRosterUpdateValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export interface SaveVendorCompanyRosterInput { + mode: "create" | "update"; + values: VendorCompanyRosterFormValues; + companyId?: string | number | null; + rowVersion?: string; + originalRoster?: VendorCompanyRoster; +} + +export interface SaveVendorCompanyRosterContext { + conflict?: unknown; +} + +const COMPANY_FIELDS = [ + "name", + "companyPhone", + "email", + "address", + "city", + "state", + "zip", + "googleMapsUrl", + "notes", +] as const; + +const TECHNICIAN_FIELDS = [ + "id", + "contactName", + "phone", + "email", + "preferredContact", + "tradeSpecialties", +] as const; + +export function getSingleStatusOnlyChange( + original: VendorCompanyRoster, + values: VendorCompanyRosterFormValues, +): { id: string | number; isActive: boolean } | null { + if (COMPANY_FIELDS.some((field) => original[field] !== values[field])) return null; + if (original.technicians.length !== values.technicians.length) return null; + + const changed: Array<{ id: string | number; isActive: boolean }> = []; + for (let index = 0; index < original.technicians.length; index += 1) { + const before = original.technicians[index]; + const after = values.technicians[index]; + if (!before || !after || TECHNICIAN_FIELDS.some((field) => before[field] !== after[field])) { + return null; + } + if (before.isActive !== after.isActive) { + if (before.id == null) return null; + changed.push({ id: before.id, isActive: after.isActive }); + } + } + return changed.length === 1 ? changed[0] : null; +} + +export function useSaveVendorCompanyRoster(): UseMutationResult< + VendorCompanyRoster, + unknown, + SaveVendorCompanyRosterInput, + SaveVendorCompanyRosterContext +> { + const queryClient = useQueryClient(); + + return useMutation< + VendorCompanyRoster, + unknown, + SaveVendorCompanyRosterInput, + SaveVendorCompanyRosterContext + >({ + mutationFn: async ({ + mode, + values, + companyId, + rowVersion, + originalRoster, + }: SaveVendorCompanyRosterInput) => { + if (mode === "update") { + const statusChange = originalRoster + ? getSingleStatusOnlyChange(originalRoster, values) + : null; + if (statusChange) { + const baseRoster = originalRoster as VendorCompanyRoster; + await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive }); + return { + ...baseRoster, + technicians: baseRoster.technicians.map((technician) => + technician.id === statusChange.id + ? { ...technician, isActive: statusChange.isActive } + : technician, + ), + }; + } + if (companyId === undefined || companyId === null || companyId === "") { + throw new Error("Company id is required to update"); + } + if (!rowVersion) throw new Error("Row version is required to update"); + return vendorCompanyRosterApi.update(companyId, values, rowVersion); + } + return vendorCompanyRosterApi.create(values); + }, + onSuccess: (data, variables) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); + if (variables.mode === "update" && variables.companyId) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.vendors.roster("companyId", variables.companyId), + }); + } + toast.success( + variables.mode === "update" ? "Vendor company updated" : "Vendor company created", + ); + }, + onError: (error) => { + if (isVendorRosterConflictError(error)) return; + toast.error("Failed to save vendor company. Try again."); + }, + }); +} + +export type { VendorCompanyRosterFormValues, VendorCompanyRosterUpdateValues }; diff --git a/src/domain/vendors/use-cases/use-vendor-company-roster.ts b/src/domain/vendors/use-cases/use-vendor-company-roster.ts new file mode 100644 index 00000000..882e5869 --- /dev/null +++ b/src/domain/vendors/use-cases/use-vendor-company-roster.ts @@ -0,0 +1,36 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { + vendorCompanyRosterApi, + type RosterQuery, +} from "@/domain/vendors/api/vendor-company-roster-api"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function buildQuery(vendorId?: string | number, companyId?: string | number): RosterQuery | null { + const hasVendorId = vendorId !== undefined && vendorId !== null && vendorId !== ""; + const hasCompanyId = companyId !== undefined && companyId !== null && companyId !== ""; + if (!hasVendorId && !hasCompanyId) return null; + return { + vendorId: hasVendorId ? vendorId : undefined, + companyId: hasCompanyId ? companyId : undefined, + }; +} + +export function useVendorCompanyRoster( + vendorId?: string | number, + companyId?: string | number, +): UseQueryResult { + const query = buildQuery(vendorId, companyId); + const by = query?.companyId !== undefined ? "companyId" : "vendorId"; + const id = (query?.companyId ?? query?.vendorId ?? "") as string | number; + + return useQuery({ + queryKey: queryKeys.vendors.roster(by, id), + queryFn: () => { + if (!query) throw new Error("Vendor id or company id is required"); + return vendorCompanyRosterApi.get(query); + }, + enabled: query !== null, + staleTime: 0, + }); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index e6173504..c136993d 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -73,6 +73,8 @@ export const queryKeys = { [...queryKeys.vendors.all, "portalToken", vendorId] as const, deactivationImpact: (vendorId: string | number) => [...queryKeys.vendors.all, "deactivationImpact", vendorId] as const, + roster: (by: "vendorId" | "companyId", id: string | number) => + [...queryKeys.vendors.all, "roster", by, id] as const, }, calendar: { all: ["calendar"] as const, diff --git a/src/lib/format/na-phone.ts b/src/lib/format/na-phone.ts new file mode 100644 index 00000000..9bcc08b8 --- /dev/null +++ b/src/lib/format/na-phone.ts @@ -0,0 +1,7 @@ +export function formatNorthAmericanPhone(input: string): string { + const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10); + if (digits.length === 0) return ""; + if (digits.length < 4) return `(${digits}`; + if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx new file mode 100644 index 00000000..e083f352 --- /dev/null +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -0,0 +1,275 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useController } from "react-hook-form"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rosterGet = vi.fn(); + +vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ + vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) }, +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ + useVendorCompanyRoster: () => ({ + data: undefined, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ + useVendorFacets: () => ({ data: { companies: [], trades: [] } }), +})); + +vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => { + const actual = await vi.importActual< + typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster") + >("@/domain/vendors/use-cases/use-save-vendor-company-roster"); + return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) }; +}); + +import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; +import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor"; + +const company: VendorFacetCompany = { + companyId: 5, + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "1 Main St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "", +}; + +const roster: VendorCompanyRoster = { + companyId: 5, + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "1 Main St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "", + notes: "", + technicians: [], +}; + +const secondCompany: VendorFacetCompany = { + ...company, + companyId: 6, + name: "Metro HVAC", + companyPhone: "(314) 555-0110", + email: "dispatch@metro.test", +}; + +const secondRoster: VendorCompanyRoster = { + ...roster, + companyId: 6, + name: "Metro HVAC", + companyPhone: "(314) 555-0110", + email: "dispatch@metro.test", +}; + +function createClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function deferRoster(): { + promise: Promise; + resolve: (value: VendorCompanyRoster) => void; +} { + let resolve!: (value: VendorCompanyRoster) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +describe("useVendorRosterForm load-error handling", () => { + beforeEach(() => { + rosterGet.mockReset(); + }); + + it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => { + rosterGet.mockRejectedValueOnce(new Error("Internal server error")); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + expect(result.current.loadErrorMessage).toBeNull(); + + await act(async () => { + await result.current.selectCompany(company); + }); + + expect(result.current.loadErrorMessage).toBe("Couldn't load that company's roster. Try again."); + expect(result.current.selectedCompanyId).toBeNull(); + }); + + it("preserves entered form data when selecting an existing company fetch is rejected", async () => { + rosterGet.mockRejectedValueOnce(new Error("Internal server error")); + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create" }); + const name = useController({ control: form.control, name: "name" }); + return { form, name }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + await act(async () => { + result.current.name.field.onChange("Draft Vendor"); + }); + expect(result.current.name.field.value).toBe("Draft Vendor"); + + await act(async () => { + await result.current.form.selectCompany(company); + }); + + expect(result.current.name.field.value).toBe("Draft Vendor"); + expect(result.current.form.loadErrorMessage).toBe( + "Couldn't load that company's roster. Try again.", + ); + }); + + it("recovers and populates the form on retry after a rejected fetch", async () => { + rosterGet.mockRejectedValueOnce(new Error("boom")); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + expect(result.current.loadErrorMessage).not.toBeNull(); + + rosterGet.mockResolvedValueOnce(roster); + await act(async () => { + result.current.retryLoad(); + }); + await waitFor(() => expect(result.current.loadErrorMessage).toBeNull()); + + expect(result.current.selectedCompanyId).toBe(5); + }); + + it("clears the selected roster when the form is reset", async () => { + rosterGet.mockResolvedValueOnce(roster); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + expect(result.current.selectedCompanyId).toBe(5); + + act(() => result.current.resetForm()); + + expect(result.current.selectedCompanyId).toBeNull(); + }); + + it("does not expose raw server details in the error message", async () => { + rosterGet.mockRejectedValueOnce(new Error("Database connection failed at 10.0.0.1")); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + + expect(result.current.loadErrorMessage).not.toContain("Database"); + expect(result.current.loadErrorMessage).not.toContain("10.0.0.1"); + }); +}); + +describe("useVendorRosterForm stale-selection handling", () => { + beforeEach(() => { + rosterGet.mockReset(); + }); + + it("keeps the newest company roster when two lookups resolve out of order", async () => { + const first = deferRoster(); + const second = deferRoster(); + rosterGet.mockImplementationOnce(() => first.promise); + rosterGet.mockImplementationOnce(() => second.promise); + + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create" }); + const name = useController({ control: form.control, name: "name" }); + return { form, name }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + let firstSelect!: Promise; + let secondSelect!: Promise; + act(() => { + firstSelect = result.current.form.selectCompany(company); + }); + act(() => { + secondSelect = result.current.form.selectCompany(secondCompany); + }); + + await act(async () => { + second.resolve(secondRoster); + await secondSelect; + }); + await act(async () => { + first.resolve(roster); + await firstSelect; + }); + + expect(result.current.form.selectedCompanyId).toBe(6); + expect(result.current.name.field.value).toBe("Metro HVAC"); + }); + + it("keeps a free-text draft when a pending lookup resolves after clearing to free text", async () => { + const lookup = deferRoster(); + rosterGet.mockImplementationOnce(() => lookup.promise); + + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create" }); + const name = useController({ control: form.control, name: "name" }); + return { form, name }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + let pendingSelect!: Promise; + act(() => { + pendingSelect = result.current.form.selectCompany(company); + }); + + act(() => { + result.current.form.clearSelectedCompany("Draft Vendor"); + }); + await act(async () => { + result.current.name.field.onChange("Draft Vendor"); + }); + expect(result.current.name.field.value).toBe("Draft Vendor"); + + await act(async () => { + lookup.resolve(roster); + await pendingSelect; + }); + + expect(result.current.form.selectedCompanyId).toBeNull(); + expect(result.current.name.field.value).toBe("Draft Vendor"); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx new file mode 100644 index 00000000..a55ddaf0 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -0,0 +1,90 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { VendorDetailDrawer } from "@/app/(protected)/vendors/_components/vendor-detail-drawer"; +import { renderWithProviders } from "@/test/test-utils"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; + +const useVendorCompanyRoster = vi.fn(); + +vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ + useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args), +})); + +const vendor: VendorListItem = { + id: 1, + companyId: "co-1", + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + email: "", + phone: "", + companyPhone: "", + googleMapsUrl: "", + notes: "", + totalJobs: 0, + city: "", + state: "", + tradeSpecialties: "", + isActive: true, + preferredContact: "Phone", +}; + +function rosterWith(technicians: Array>) { + return { + data: { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians, + }, + isLoading: false, + error: null, + }; +} + +describe("VendorDetailDrawer preferred-contact display", () => { + it("renders no preference label when preferredContact is absent", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("Adam")).toBeInTheDocument(); + expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); + }); + + it("renders the preference label when an explicit preferredContact is present", () => { + useVendorCompanyRoster.mockReturnValue( + rosterWith([ + { + id: 7, + contactName: "Adam", + phone: "314-555-0198", + preferredContact: "Email", + isActive: true, + totalJobs: 5, + }, + ]), + ); + + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); + + expect(screen.getByText("Prefers Email")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx b/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx deleted file mode 100644 index d1e0dbcc..00000000 --- a/src/test/app/(protected)/vendors/vendor-form-fields.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm, useWatch, type FieldErrors } from "react-hook-form"; -import { describe, expect, it } from "vitest"; -import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields"; -import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { VendorFacetCompany } from "@/domain/vendors/types/vendor"; -import { renderWithProviders } from "@/test/test-utils"; - -const companies: VendorFacetCompany[] = [ - { - companyId: "co-1", - name: "Gateway Plumbing", - companyPhone: "(314) 555-0198", - email: "dispatch@gatewayplumbing.com", - address: "1 Main St", - city: "St. Louis", - state: "MO", - zip: "63101", - googleMapsUrl: "https://maps.google.com/gateway", - }, -]; - -const emptyForm: VendorFormValues = { - companyName: "", - contactName: "Tech Person", - email: "", - phone: "", - companyPhone: "", - googleMapsUrl: "", - notes: "", - totalJobs: 0, - address: "", - city: "", - state: "", - zip: "", - tradeSpecialties: "", - isActive: true, - companyId: null, -}; - -function Harness({ - defaultValues, - resetTarget, -}: { - defaultValues: VendorFormValues; - resetTarget: VendorFormValues; -}) { - const { control, reset, setValue } = useForm({ - resolver: zodResolver(vendorSchema), - defaultValues, - }); - - const onCompanySelected = (company: VendorFacetCompany | null) => { - if (!company) return; - setValue("companyId", company.companyId); - setValue("companyPhone", company.companyPhone); - setValue("email", company.email); - setValue("address", company.address); - setValue("city", company.city); - setValue("state", company.state); - setValue("zip", company.zip); - setValue("googleMapsUrl", company.googleMapsUrl); - }; - - const companyId = useWatch({ control, name: "companyId" }); - - return ( - <> - } - companies={companies} - tradeOptions={[]} - onCompanySelected={onCompanySelected} - /> - {String(companyId ?? "")} - - - ); -} - -const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement; -const companyIdOutput = () => screen.getByTestId("company-id"); - -describe("VendorFormFields company autocomplete state", () => { - it("clears a previously typed company when the form is reset (create-form reopen)", async () => { - renderWithProviders(, { - withAuth: false, - }); - - await userEvent.type(companyField(), "Leftover Co"); - expect(companyField()).toHaveValue("Leftover Co"); - - await userEvent.click(screen.getByRole("button", { name: "reset-form" })); - - expect(companyField()).toHaveValue(""); - }); - - it("shows the switched company value instead of previously typed text when the form is reset", async () => { - renderWithProviders( - , - { withAuth: false }, - ); - - await userEvent.type(companyField(), "Acme"); - expect(companyField()).toHaveValue("Acme"); - - await userEvent.click(screen.getByRole("button", { name: "reset-form" })); - - expect(companyField()).toHaveValue("Beta LLC"); - }); - - it("reflects the company provided by an external reset even when nothing was typed", async () => { - renderWithProviders( - , - { withAuth: false }, - ); - - expect(companyField()).toHaveValue("Acme Corp"); - - await userEvent.click(screen.getByRole("button", { name: "reset-form" })); - - expect(companyField()).toHaveValue("Beta LLC"); - }); - - it("updates the displayed company as the user types (freeSolo)", async () => { - renderWithProviders(, { - withAuth: false, - }); - - await userEvent.type(companyField(), "Acme"); - - expect(companyField()).toHaveValue("Acme"); - }); - - it("selects a company option, autofills company details, and shows the company name", async () => { - renderWithProviders(, { - withAuth: false, - }); - - await userEvent.type(companyField(), "Gate"); - const option = await screen.findByRole("option", { name: "Gateway Plumbing" }); - await userEvent.click(option); - - expect(companyField()).toHaveValue("Gateway Plumbing"); - expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198"); - }); - - it("sets companyId when a known company is selected and clears it when the text changes", async () => { - renderWithProviders(, { - withAuth: false, - }); - - await userEvent.type(companyField(), "Gate"); - await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" })); - expect(companyIdOutput()).toHaveTextContent("co-1"); - - await userEvent.type(companyField(), "!"); - expect(companyIdOutput()).toHaveTextContent(""); - }); - - it("keeps companyId null for free-typed company text that matches no option", async () => { - renderWithProviders(, { - withAuth: false, - }); - - await userEvent.type(companyField(), "Independent Vendor LLC"); - expect(companyIdOutput()).toHaveTextContent(""); - }); - - it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => { - renderWithProviders(, { - withAuth: false, - }); - - const phone = screen.getByRole("textbox", { name: "Technician Phone" }); - await userEvent.type(phone, "3145550198"); - expect(phone).toHaveValue("(314) 555-0198"); - - const companyPhone = screen.getByRole("textbox", { name: "Company Phone" }); - await userEvent.type(companyPhone, "3145550100"); - expect(companyPhone).toHaveValue("(314) 555-0100"); - }); -}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 995dd5dc..64c71c69 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -2,19 +2,26 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; +import type { VendorListItem } from "@/domain/vendors/types/vendor"; import { renderWithProviders } from "@/test/test-utils"; const useVendorsList = vi.fn(); -const useVendorDetail = vi.fn(); +const useVendorCompanyRoster = vi.fn(); const useVendorDeactivationImpact = vi.fn(); const mutate = vi.fn(); +const navigate = vi.fn(); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { ...actual, useNavigate: () => navigate }; +}); vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({ useVendorsList: (...args: unknown[]) => useVendorsList(...args), })); -vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({ - useVendorDetail: (...args: unknown[]) => useVendorDetail(...args), +vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ + useVendorCompanyRoster: (...args: unknown[]) => useVendorCompanyRoster(...args), })); vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({ @@ -29,13 +36,17 @@ vi.mock("@/hooks/use-debounce", () => ({ useDebounce: (value: string) => value, })); -const activeVendor = { +const activeVendor: VendorListItem = { id: 1, companyId: "co-1", companyName: "Gateway Plumbing", contactName: "Adam Whyte", email: "dispatch@gatewayplumbing.com", phone: "(314) 555-0198", + companyPhone: "(314) 555-0100", + googleMapsUrl: "", + notes: "", + totalJobs: 0, city: "St. Louis", state: "MO", tradeSpecialties: "Backflow Preventers", @@ -50,7 +61,7 @@ const inactiveVendor = { isActive: false, }; -function result(items: (typeof activeVendor)[], totalCount: number) { +function result(items: VendorListItem[], totalCount: number) { return { data: { items, totalCount, page: 1, pageSize: 12 }, isLoading: false, @@ -60,7 +71,7 @@ function result(items: (typeof activeVendor)[], totalCount: number) { } function setupDefaults() { - useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null }); + useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); useVendorDeactivationImpact.mockReturnValue({ data: undefined, isLoading: false, @@ -103,13 +114,40 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); - expect(screen.getByText(/25 active technicians/)).toBeInTheDocument(); - expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument(); + expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); + expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); }); it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { setupDefaults(); - useVendorDetail.mockReturnValue({ data: activeVendor, isLoading: false, error: null }); + useVendorCompanyRoster.mockReturnValue({ + data: { + companyId: "co-1", + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + address: "", + city: "St. Louis", + state: "MO", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "(314) 555-0198", + email: "dispatch@gatewayplumbing.com", + preferredContact: "Email", + tradeSpecialties: "Backflow Preventers", + isActive: true, + }, + ], + }, + isLoading: false, + error: null, + }); useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { if (params.pageSize === 1) return result([], 1); return params.isActive ? result([activeVendor], 1) : result([], 0); @@ -160,4 +198,38 @@ describe("VendorsListPage", () => { expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); expect(mutate).not.toHaveBeenCalled(); }); + + it("navigates to the vendor edit route when the table Edit control is clicked", async () => { + setupDefaults(); + navigate.mockClear(); + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([activeVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + + expect(navigate).toHaveBeenCalledWith("/vendors/edit/1"); + expect( + screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }), + ).not.toBeInTheDocument(); + }); + + it("navigates to the company route when editing a vendor without a vendor id", async () => { + setupDefaults(); + navigate.mockClear(); + const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" }; + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([companyOnlyVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); + + expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); + }); }); diff --git a/src/test/domain/vendors/api/vendor-company-roster-api.test.ts b/src/test/domain/vendors/api/vendor-company-roster-api.test.ts new file mode 100644 index 00000000..1bad51e4 --- /dev/null +++ b/src/test/domain/vendors/api/vendor-company-roster-api.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiPut = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiPut: (...args: unknown[]) => apiPut(...args), +})); + +vi.mock("ky", () => ({ + isHTTPError: (error: unknown) => + error !== null && typeof error === "object" && "__kyHTTPError" in error, +})); + +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; + +function httpError(status: number, body: unknown): Error { + const error = new Error("HTTPError") as Error & { + __kyHTTPError: true; + response: { status: number; json: () => Promise; text: () => Promise }; + }; + error.__kyHTTPError = true; + error.response = { + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; + return error; +} + +describe("vendorCompanyRosterApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiPut.mockReset(); + }); + + it("fetches by vendorId or companyId with the correct query params", async () => { + apiGet.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } }); + + await vendorCompanyRosterApi.get({ vendorId: 7 }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendorCompanyRoster.base, { + searchParams: { vendorId: "7" }, + }); + + apiGet.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } }); + await vendorCompanyRosterApi.get({ companyId: 5 }); + expect(apiGet).toHaveBeenLastCalledWith(API_PATHS.vendorCompanyRoster.base, { + searchParams: { companyId: "5" }, + }); + }); + + it("posts the canonicalized create payload", async () => { + apiPost.mockResolvedValueOnce({ data: { companyId: 9, name: "Solo Co" } }); + + await vendorCompanyRosterApi.create({ + name: "Solo Co", + companyPhone: "314-555-0100", + technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }], + }); + + expect(apiPost).toHaveBeenCalledWith( + API_PATHS.vendorCompanyRoster.base, + expect.objectContaining({ + name: "Solo Co", + companyPhone: "(314) 555-0100", + technicians: [expect.objectContaining({ phone: "(314) 555-0198" })], + }), + ); + expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("rowVersion"); + }); + + it("puts the rowVersion on the update payload", async () => { + apiPut.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } }); + + await vendorCompanyRosterApi.update(5, { name: "Solo Co" }, "rv-1"); + + expect(apiPut).toHaveBeenCalledWith( + API_PATHS.vendorCompanyRoster.byCompany(5), + expect.objectContaining({ name: "Solo Co", rowVersion: "rv-1" }), + ); + }); + + it("throws an open-work-order conflict on a 409 with blocked work orders", async () => { + apiPut.mockRejectedValueOnce( + httpError(409, { + blockedWorkOrders: [ + { workOrderId: 501, workOrderNumber: "WO-501", workOrderTitle: "Boiler" }, + ], + }), + ); + + await expect(vendorCompanyRosterApi.update(5, { name: "Solo Co" }, "rv-1")).rejects.toSatisfy( + (error: unknown) => { + if (!isVendorRosterConflictError(error)) return false; + return error.conflict.kind === "open-work-orders"; + }, + ); + }); + + it("throws a stale conflict on a 409 without blocked work orders", async () => { + apiPut.mockRejectedValueOnce(httpError(409, { message: "rowversion mismatch" })); + + await expect(vendorCompanyRosterApi.update(5, { name: "Solo Co" }, "rv-1")).rejects.toSatisfy( + (error: unknown) => { + if (!isVendorRosterConflictError(error)) return false; + return error.conflict.kind === "stale"; + }, + ); + }); + + it("rethrows non-conflict errors untouched", async () => { + const generic = new Error("boom"); + apiPost.mockRejectedValueOnce(generic); + + await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toBe(generic); + }); +}); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts new file mode 100644 index 00000000..66e8d28b --- /dev/null +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + mapRosterConflict, + mapRosterTechnician, + mapVendorCompanyRoster, + mapVendorRosterToBackend, +} from "@/domain/vendors/mappers/vendor-roster-mapper"; + +describe("vendor roster mapper", () => { + it("maps a PascalCase aggregate payload including technicians", () => { + const roster = mapVendorCompanyRoster({ + CompanyId: 101, + RowVersion: "rv-1", + Name: "Gateway Plumbing", + CompanyPhone: "314-555-0100", + Email: "dispatch@gateway.test", + Technicians: [ + { + Id: 7, + ContactName: "Adam Whyte", + Phone: "314-555-0198", + Email: "adam@gateway.test", + PreferredContact: "Email", + TradeSpecialties: "Plumbing", + IsActive: true, + TotalJobs: 72, + }, + ], + }); + + expect(roster).toEqual({ + companyId: 101, + rowVersion: "rv-1", + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes: "", + technicians: [ + { + id: 7, + contactName: "Adam Whyte", + phone: "314-555-0198", + email: "adam@gateway.test", + preferredContact: "Email", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 72, + }, + ], + }); + }); + + it("maps a company-only roster with a null vendor id and zero technicians", () => { + const roster = mapVendorCompanyRoster({ companyId: 5, name: "Solo Co" }); + expect(roster.companyId).toBe(5); + expect(roster.technicians).toEqual([]); + }); + + it("canonicalizes phones and carries rowVersion on the backend payload", () => { + const payload = mapVendorRosterToBackend({ + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + googleMapsUrl: "https://maps.google.com/x", + tradeSpecialties: "Plumbing", + rowVersion: "rv-2", + technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Phone" }], + }); + + expect(payload).toMatchObject({ + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", + rowVersion: "rv-2", + }); + expect(payload.technicians).toEqual([ + expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" }), + ]); + expect(payload).not.toHaveProperty("tradeSpecialties"); + }); + + it("omits rowVersion from the create payload when absent", () => { + const payload = mapVendorRosterToBackend({ name: "Solo Co", companyPhone: "314-555-0100" }); + expect(payload).not.toHaveProperty("rowVersion"); + }); + + it("maps an open-work-order conflict payload", () => { + const conflict = mapRosterConflict( + { + blockedWorkOrders: [ + { + workOrderId: 501, + workOrderNumber: "WO-501", + workOrderTitle: "Boiler repair", + status: "Dispatched", + }, + ], + }, + "fallback", + ); + expect(conflict.kind).toBe("open-work-orders"); + expect(conflict.blockedWorkOrders[0]?.workOrderNumber).toBe("WO-501"); + }); + + it("classifies a body without blocked work orders as stale", () => { + const conflict = mapRosterConflict({ message: "stale rowversion" }, "fallback"); + expect(conflict.kind).toBe("stale"); + expect(conflict.message).toBe("stale rowversion"); + }); + + it("preserves an explicit Phone/Email/Text preferred contact on read", () => { + expect( + mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact, + ).toBe("Phone"); + expect( + mapRosterTechnician({ contactName: "B", preferredContact: "Email" }).preferredContact, + ).toBe("Email"); + expect( + mapRosterTechnician({ contactName: "C", preferredContact: "Text" }).preferredContact, + ).toBe("Text"); + }); + + it("preserves an absent preferred contact as absent on read", () => { + const tech = mapRosterTechnician({ contactName: "Adam" }); + expect(tech.preferredContact).toBeUndefined(); + }); + + it("preserves an unknown preferred contact as absent on read", () => { + const tech = mapRosterTechnician({ contactName: "Adam", preferredContact: "Fax" }); + expect(tech.preferredContact).toBeUndefined(); + }); + + it("omits preferredContact from the backend payload when absent", () => { + const payload = mapVendorRosterToBackend({ + name: "Solo Co", + companyPhone: "314-555-0100", + technicians: [{ contactName: "Adam", phone: "3145550198" }], + }); + const tech = (payload.technicians as Array>)[0]; + expect(tech).not.toHaveProperty("preferredContact"); + }); + + it("keeps an explicit preferredContact in the backend payload", () => { + const payload = mapVendorRosterToBackend({ + name: "Solo Co", + companyPhone: "314-555-0100", + technicians: [{ contactName: "Adam", phone: "3145550198", preferredContact: "Email" }], + }); + const tech = (payload.technicians as Array>)[0]; + expect(tech.preferredContact).toBe("Email"); + }); +}); diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts new file mode 100644 index 00000000..6b94dd90 --- /dev/null +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + emptyRosterTechnician, + emptyVendorCompanyRosterForm, + isAbsoluteHttpsUrl, + vendorCompanyRosterSchema, + vendorCompanyRosterUpdateSchema, +} from "@/domain/vendors/schemas/vendor-roster-schema"; + +const baseCompany = { + ...emptyVendorCompanyRosterForm, + name: "Gateway Plumbing", + companyPhone: "(314) 555-0100", + email: "dispatch@gateway.test", +}; + +describe("vendorCompanyRosterSchema", () => { + it("requires a company name", () => { + const result = vendorCompanyRosterSchema.safeParse({ ...baseCompany, name: "" }); + expect(result.success).toBe(false); + }); + + it("requires a company phone or email even with no technicians", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...emptyVendorCompanyRosterForm, + name: "Solo Co", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message === "Company phone or email is required"), + ).toBe(true); + } + }); + + it("accepts a company with zero technicians when phone is present", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...emptyVendorCompanyRosterForm, + name: "Solo Co", + companyPhone: "(314) 555-0100", + }); + expect(result.success).toBe(true); + }); + + it("accepts a company with email only and multiple technicians", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + companyPhone: "", + technicians: [ + { ...emptyRosterTechnician, contactName: "Adam", phone: "314-555-0198" }, + { ...emptyRosterTechnician, contactName: "Beth", phone: "314-555-0200" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("accepts a technician row without a name or phone", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + technicians: [{ ...emptyRosterTechnician, contactName: "" }], + }); + expect(result.success).toBe(true); + }); + + it("rejects malformed emails, non-HTTPS map URLs, and partial phones", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + email: "not-an-email", + googleMapsUrl: "http://maps.google.com/x", + technicians: [{ ...emptyRosterTechnician, contactName: "Adam", phone: "(314)" }], + }); + expect(result.success).toBe(false); + }); + + it("rejects a maps URL that is a non-absolute https prefix string", () => { + expect(isAbsoluteHttpsUrl("https://")).toBe(false); + expect(isAbsoluteHttpsUrl("maps.google.com/x")).toBe(false); + expect(isAbsoluteHttpsUrl("https://maps.google.com/x")).toBe(true); + expect(isAbsoluteHttpsUrl("")).toBe(true); + }); + + it("rejects an unknown preferred contact", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + technicians: [ + { ...emptyRosterTechnician, contactName: "Adam", preferredContact: "Fax" as never }, + ], + }); + expect(result.success).toBe(false); + }); + + it("accepts a technician with an absent preferred contact", () => { + const techWithoutPreference = { ...emptyRosterTechnician }; + delete techWithoutPreference.preferredContact; + const result = vendorCompanyRosterSchema.safeParse({ + ...baseCompany, + technicians: [{ ...techWithoutPreference, contactName: "Adam", phone: "314-555-0198" }], + }); + expect(result.success).toBe(true); + }); + + it("update schema requires a non-empty row version and company id", () => { + const result = vendorCompanyRosterUpdateSchema.safeParse({ + ...baseCompany, + companyId: 42, + rowVersion: "", + }); + expect(result.success).toBe(false); + }); + + it("exposes an empty default form with no technicians", () => { + expect(emptyVendorCompanyRosterForm.technicians).toEqual([]); + }); +}); diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts new file mode 100644 index 00000000..f0547378 --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; + +const roster: VendorCompanyRoster = { + companyId: 10, + rowVersion: "rv-1", + name: "Legacy Plumbing", + companyPhone: "555-1234 ext 9", + email: "dispatch@example.test", + address: "1 Main St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "http://maps.example.test/legacy", + notes: "Legacy values remain untouched", + technicians: [ + { + id: 7, + contactName: "Taylor", + phone: "555-0199 ext 2", + email: "taylor@example.test", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: false, + totalJobs: 12, + }, + ], +}; + +const values = { + name: roster.name, + companyPhone: roster.companyPhone, + email: roster.email, + address: roster.address, + city: roster.city, + state: roster.state, + zip: roster.zip, + googleMapsUrl: roster.googleMapsUrl, + notes: roster.notes, + technicians: roster.technicians.map((technician) => ({ + id: technician.id, + contactName: technician.contactName, + phone: technician.phone, + email: technician.email, + preferredContact: technician.preferredContact, + tradeSpecialties: technician.tradeSpecialties, + isActive: technician.isActive, + })), +}; + +describe("getSingleStatusOnlyChange", () => { + it("preserves the sparse legacy status path when exactly one technician status changes", () => { + expect( + getSingleStatusOnlyChange(roster, { + ...values, + technicians: [{ ...values.technicians[0], isActive: true }], + }), + ).toEqual({ id: 7, isActive: true }); + }); + + it("requires aggregate reconciliation when company or technician data changes", () => { + expect(getSingleStatusOnlyChange(roster, { ...values, notes: "changed" })).toBeNull(); + expect( + getSingleStatusOnlyChange(roster, { + ...values, + technicians: [{ ...values.technicians[0], contactName: "Renamed", isActive: true }], + }), + ).toBeNull(); + }); +});