diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 6bf89b1f..61f6f678 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from "react"; +import { toast } from "react-toastify"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, @@ -8,6 +9,7 @@ import { } from "@/domain/vendors/schemas/vendor-roster-schema"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; import { + buildAdditiveRosterPatch, getSingleStatusOnlyChange, useSaveVendorCompanyRoster, } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; @@ -129,17 +131,38 @@ export function useVendorRosterForm({ clearConflict(); }, [clearConflict, createDefaults, reset, resetSelection]); - const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; - const isUpdate = mode === "update" || selection.selectedRoster != null; - const submit = (formValues: VendorCompanyRosterFormValues) => { setConflict(null); + const values = withoutBlankNewTechnicians(formValues); + if (mode === "create" && selection.selectedRoster != null) { + const selected = selection.selectedRoster; + if (!buildAdditiveRosterPatch(selected, values, selected.rowVersion)) { + toast.error("Enter at least one technician to add to this vendor."); + return; + } + save.mutate( + { + mode: "add", + values, + companyId: selected.companyId, + rowVersion: selected.rowVersion, + originalRoster: selected, + }, + { + onSuccess: (data) => onSuccess?.(data), + onError: (error) => { + if (isVendorRosterConflictError(error)) setConflict(error.conflict); + }, + }, + ); + return; + } save.mutate( { - mode: isUpdate ? "update" : "create", - values: withoutBlankNewTechnicians(formValues), - companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, - rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, + mode: mode === "update" ? "update" : "create", + values, + companyId: mode === "update" ? (routeRoster?.companyId ?? companyId) : undefined, + rowVersion: mode === "update" ? routeRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, }, { diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index cce00e18..4eb141b1 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { useQueryClient, type UseQueryResult } from "@tanstack/react-query"; import { + emptyRosterTechnician, emptyVendorCompanyRosterForm, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -53,6 +54,20 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo }; } +export function toCompanyFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues { + return { ...toFormValues(roster), technicians: [] }; +} + +function withCompanyFieldsOnly( + roster: VendorCompanyRoster, +): (current: VendorCompanyRosterFormValues) => VendorCompanyRosterFormValues { + return (current) => ({ + ...toCompanyFormValues(roster), + technicians: + current.technicians.length > 0 ? current.technicians : [{ ...emptyRosterTechnician }], + }); +} + export function useVendorRosterSelection({ mode, reset, @@ -91,7 +106,7 @@ export function useVendorRosterSelection({ try { const roster = await fetchRosterByCompany(company.companyId); if (requestIdRef.current !== requestId) return; - reset(toFormValues(roster)); + reset(withCompanyFieldsOnly(roster)); setSelectedRoster(roster); clearConflict(); retryTargetRef.current = null; @@ -107,7 +122,11 @@ export function useVendorRosterSelection({ (nextName = "") => { requestIdRef.current += 1; if (selectedRoster) { - reset({ ...emptyVendorCompanyRosterForm, name: nextName }); + reset((current) => ({ + ...emptyVendorCompanyRosterForm, + name: nextName, + technicians: current.technicians, + })); } setSelectedRoster(null); clearConflict(); @@ -134,7 +153,6 @@ export function useVendorRosterSelection({ void fetchRosterByCompany(selectedRoster.companyId) .then((roster) => { if (requestIdRef.current !== requestId) return; - reset(toFormValues(roster)); setSelectedRoster(roster); retryTargetRef.current = null; }) @@ -143,7 +161,7 @@ export function useVendorRosterSelection({ setLoadErrorMessage(RELOAD_ERROR_MESSAGE); }); } - }, [clearConflict, fetchRosterByCompany, mode, query, reset, selectedRoster]); + }, [clearConflict, fetchRosterByCompany, mode, query, selectedRoster]); const retryLoad = useCallback(() => { const target = retryTargetRef.current; diff --git a/src/domain/vendors/api/vendor-company-roster-api.ts b/src/domain/vendors/api/vendor-company-roster-api.ts index dd9c978a..fb051bbb 100644 --- a/src/domain/vendors/api/vendor-company-roster-api.ts +++ b/src/domain/vendors/api/vendor-company-roster-api.ts @@ -1,14 +1,16 @@ import { isHTTPError } from "ky"; import { API_PATHS } from "@/api/api-paths"; -import { apiGet, apiPost, apiPut } from "@/api/api"; +import { apiGet, apiPatch, apiPost, apiPut } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; import { mapRosterConflict, mapVendorCompanyRoster, + mapVendorRosterAdditivePatchToBackend, 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"; +import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper"; const STALE_MESSAGE = "This company was changed by another session. Reload the latest version and try again."; @@ -97,4 +99,22 @@ export const vendorCompanyRosterApi = { throw error; } }, + + addTechnicians: async ( + companyId: string | number, + patch: VendorRosterAdditivePatch, + ): Promise => { + try { + const data = await apiPatch( + API_PATHS.vendorCompanyRoster.byCompany(companyId), + mapVendorRosterAdditivePatchToBackend(patch), + ); + return mapVendorCompanyRoster(handleApiResponse(data)); + } catch (error) { + if (isHTTPError(error) && error.response.status === 409) { + await throwRosterConflict(error); + } + throw error; + } + }, }; diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 8dd450c4..e4de67dd 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -134,6 +134,28 @@ export function mapVendorRosterToBackend(values: unknown): Record; +} + +export function mapVendorRosterAdditivePatchToBackend( + patch: VendorRosterAdditivePatch, +): Record { + const item = asRecord(patch); + const payload: Record = { + rowVersion: readString(item, "rowVersion", "RowVersion"), + addTechnicians: patch.addTechnicians.map((technician) => { + const mapped = mapRosterTechnicianToBackend(technician); + delete mapped.id; + return mapped; + }), + }; + if (patch.companyFields) payload.companyFields = { ...patch.companyFields }; + return payload; +} + function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder { const item = asRecord(raw); return { 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 index 37532220..1d1c4865 100644 --- a/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts +++ b/src/domain/vendors/use-cases/use-save-vendor-company-roster.ts @@ -11,7 +11,7 @@ import type { import { queryKeys } from "@/infra/query-key/query-key"; export interface SaveVendorCompanyRosterInput { - mode: "create" | "update"; + mode: "create" | "update" | "add"; values: VendorCompanyRosterFormValues; companyId?: string | number | null; rowVersion?: string; @@ -65,6 +65,55 @@ export function getSingleStatusOnlyChange( return changed.length === 1 ? changed[0] : null; } +export interface VendorRosterAdditivePatchInput { + rowVersion: string; + addTechnicians: Array<{ + contactName: string; + phone: string; + email: string; + preferredContact?: string; + tradeSpecialties: string; + isActive: boolean; + }>; + companyFields?: Record; +} + +function hasTechnicianContent(technician: { + contactName: string; + phone: string; + email: string; + tradeSpecialties: string; +}): boolean { + return Boolean( + technician.contactName.trim() || + technician.phone.trim() || + technician.email.trim() || + technician.tradeSpecialties.trim(), + ); +} + +export function buildAdditiveRosterPatch( + roster: VendorCompanyRoster | null | undefined, + values: VendorCompanyRosterFormValues, + rowVersion: string, +): VendorRosterAdditivePatchInput | null { + const addTechnicians = values.technicians.filter( + (technician) => technician.id == null && hasTechnicianContent(technician), + ); + const companyFields: Record = {}; + if (roster) { + for (const field of COMPANY_FIELDS) { + if (values[field] !== roster[field]) companyFields[field] = values[field]; + } + } + if (addTechnicians.length === 0 && Object.keys(companyFields).length === 0) return null; + return { + rowVersion, + addTechnicians, + ...(Object.keys(companyFields).length > 0 ? { companyFields } : {}), + }; +} + export function useSaveVendorCompanyRoster(): UseMutationResult< VendorCompanyRoster, unknown, @@ -108,17 +157,26 @@ export function useSaveVendorCompanyRoster(): UseMutationResult< if (!rowVersion) throw new Error("Row version is required to update"); return vendorCompanyRosterApi.update(companyId, values, rowVersion); } + if (mode === "add") { + if (companyId === undefined || companyId === null || companyId === "") { + throw new Error("Company id is required to add technicians"); + } + if (!rowVersion) throw new Error("Row version is required to add technicians"); + const patch = buildAdditiveRosterPatch(originalRoster, values, rowVersion); + if (!patch) throw new Error("Nothing to add to this vendor company"); + return vendorCompanyRosterApi.addTechnicians(companyId, patch); + } return vendorCompanyRosterApi.create(values); }, onSuccess: (data, variables) => { void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); - if (variables.mode === "update" && variables.companyId) { + if ((variables.mode === "update" || variables.mode === "add") && variables.companyId) { void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.roster("companyId", variables.companyId), }); } toast.success( - variables.mode === "update" ? "Vendor company updated" : "Vendor company created", + variables.mode === "create" ? "Vendor company created" : "Vendor company updated", ); }, onError: (error) => { diff --git a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx index 2e9f8f1f..80091636 100644 --- a/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx +++ b/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx @@ -12,15 +12,19 @@ vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ })); vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({ - useVendorCompanyRoster: () => ({ - data: undefined, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn(), - }), + useVendorCompanyRoster: () => rosterQueryResult, })); +vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +let rosterQueryResult: { + data: VendorCompanyRoster | undefined; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: ReturnType; +} = { data: undefined, isLoading: false, isError: false, error: null, refetch: vi.fn() }; + vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ useVendorFacets: () => ({ data: { companies: [], trades: [] } }), })); @@ -36,6 +40,7 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => }); import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor"; const company: VendorFacetCompany = { @@ -81,6 +86,72 @@ const secondRoster: VendorCompanyRoster = { email: "dispatch@metro.test", }; +const rosterWithTechnicians: VendorCompanyRoster = { + ...roster, + technicians: [ + { + id: 11, + contactName: "Ray Holt", + phone: "(314) 555-0122", + email: "", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 0, + }, + { + id: 12, + contactName: "Amy Santiago", + phone: "(314) 555-0133", + email: "", + preferredContact: "Text", + tradeSpecialties: "HVAC", + isActive: true, + totalJobs: 0, + }, + ], +}; + +const newTechnician = { + contactName: "Dana Kim", + phone: "(314) 555-0144", + email: "dana@test.test", + preferredContact: "Text" as const, + tradeSpecialties: "HVAC", + isActive: true, +}; + +function submitValues( + technicians: VendorCompanyRosterFormValues["technicians"], +): VendorCompanyRosterFormValues { + return { + 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, + }; +} + +function resetRosterQuery(): void { + rosterQueryResult = { + data: undefined, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }; +} + +beforeEach(() => { + resetRosterQuery(); +}); + function createClient(): QueryClient { return new QueryClient({ defaultOptions: { queries: { retry: false } } }); } @@ -316,3 +387,220 @@ describe("useVendorRosterForm stale-selection handling", () => { expect(result.current.name.field.value).toBe("Draft Vendor"); }); }); + +describe("useVendorRosterForm additive add flow (SH-250)", () => { + beforeEach(() => { + rosterGet.mockReset(); + saveMutate.mockReset(); + }); + + it("issues an additive add — never update — when an existing company is selected", async () => { + rosterGet.mockResolvedValueOnce(roster); + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create", startWithTechnician: true }); + const technician = useController({ + control: form.control, + name: "technicians.0.contactName", + }); + return { form, technician }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + await act(async () => { + await result.current.form.selectCompany(company); + }); + await act(async () => { + result.current.technician.field.onChange("Dana Kim"); + }); + act(() => { + result.current.form.submit(submitValues([newTechnician])); + }); + + expect(saveMutate).toHaveBeenCalledTimes(1); + const input = saveMutate.mock.calls[0]?.[0] as { mode: string }; + expect(input.mode).toBe("add"); + expect(saveMutate).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: 5, + rowVersion: "rv-1", + originalRoster: roster, + values: expect.objectContaining({ technicians: [newTechnician] }), + }), + expect.any(Object), + ); + }); + + it("still creates via POST when no existing company is matched", () => { + const { result } = renderHook( + () => useVendorRosterForm({ mode: "create", startWithTechnician: true }), + { wrapper: makeWrapper(createClient()) }, + ); + + act(() => { + result.current.submit(submitValues([newTechnician])); + }); + + expect(saveMutate).toHaveBeenCalledTimes(1); + expect(saveMutate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ mode: "create", companyId: undefined, rowVersion: undefined }), + ); + }); + + it("blocks submit when the selected company has nothing to add", async () => { + rosterGet.mockResolvedValueOnce(roster); + const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), { + wrapper: makeWrapper(createClient()), + }); + + await act(async () => { + await result.current.selectCompany(company); + }); + act(() => { + result.current.submit(submitValues([])); + }); + + expect(saveMutate).not.toHaveBeenCalled(); + }); + + it("keeps the entered technician and retries with the fresh rowVersion after reload", async () => { + rosterGet.mockResolvedValueOnce(roster); + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create", startWithTechnician: true }); + const technician = useController({ + control: form.control, + name: "technicians.0.contactName", + }); + return { form, technician }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + await act(async () => { + await result.current.form.selectCompany(company); + }); + await act(async () => { + result.current.technician.field.onChange("Dana Kim"); + }); + act(() => { + result.current.form.submit(submitValues([newTechnician])); + }); + expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" })); + + rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" }); + await act(async () => { + result.current.form.reload(); + }); + await waitFor(() => expect(result.current.form.selectedCompanyId).toBe(5)); + + act(() => { + result.current.form.submit(submitValues([newTechnician])); + }); + expect(saveMutate.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ mode: "add", rowVersion: "rv-2" }), + ); + }); + + it("does not regress the Edit Vendor reconcile path (mode update)", () => { + rosterQueryResult = { + data: rosterWithTechnicians, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }; + const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), { + wrapper: makeWrapper(createClient()), + }); + + act(() => { + result.current.submit(submitValues([...rosterWithTechnicians.technicians, newTechnician])); + }); + + expect(saveMutate).toHaveBeenCalledTimes(1); + expect(saveMutate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + mode: "update", + companyId: 5, + rowVersion: "rv-1", + originalRoster: rosterWithTechnicians, + values: expect.objectContaining({ + technicians: [...rosterWithTechnicians.technicians, newTechnician], + }), + }), + ); + }); +}); + +describe("useVendorRosterForm technician autopopulation (SH-246)", () => { + beforeEach(() => { + rosterGet.mockReset(); + saveMutate.mockReset(); + }); + + it("does not autopopulate technician fields when a company is selected", async () => { + rosterGet.mockResolvedValueOnce(rosterWithTechnicians); + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create" }); + const technicians = useController({ control: form.control, name: "technicians" }); + const name = useController({ control: form.control, name: "name" }); + return { form, technicians, name }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + await act(async () => { + await result.current.form.selectCompany(company); + }); + + const technicians = result.current.technicians.field.value; + expect(technicians).toEqual([ + { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, + }, + ]); + expect(technicians).not.toContainEqual(expect.objectContaining({ contactName: "Ray Holt" })); + expect(technicians).not.toContainEqual( + expect.objectContaining({ contactName: "Amy Santiago" }), + ); + expect(result.current.name.field.value).toBe("Gateway Plumbing"); + expect(result.current.form.selectedCompanyId).toBe(5); + }); + + it("keeps a typed technician when the company selection is cleared to free text", async () => { + rosterGet.mockResolvedValueOnce(roster); + const { result } = renderHook( + () => { + const form = useVendorRosterForm({ mode: "create", startWithTechnician: true }); + const technician = useController({ + control: form.control, + name: "technicians.0.contactName", + }); + return { form, technician }; + }, + { wrapper: makeWrapper(createClient()) }, + ); + + await act(async () => { + await result.current.form.selectCompany(company); + }); + await act(async () => { + result.current.technician.field.onChange("Dana Kim"); + }); + + act(() => { + result.current.form.clearSelectedCompany("Draft Vendor"); + }); + + expect(result.current.form.selectedCompanyId).toBeNull(); + expect(result.current.technician.field.value).toBe("Dana Kim"); + }); +}); 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 index 1bad51e4..32fb5aeb 100644 --- a/src/test/domain/vendors/api/vendor-company-roster-api.test.ts +++ b/src/test/domain/vendors/api/vendor-company-roster-api.test.ts @@ -4,11 +4,13 @@ import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); const apiPost = vi.fn(); const apiPut = vi.fn(); +const apiPatch = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), apiPost: (...args: unknown[]) => apiPost(...args), apiPut: (...args: unknown[]) => apiPut(...args), + apiPatch: (...args: unknown[]) => apiPatch(...args), })); vi.mock("ky", () => ({ @@ -38,6 +40,7 @@ describe("vendorCompanyRosterApi", () => { apiGet.mockReset(); apiPost.mockReset(); apiPut.mockReset(); + apiPatch.mockReset(); }); it("fetches by vendorId or companyId with the correct query params", async () => { @@ -121,4 +124,72 @@ describe("vendorCompanyRosterApi", () => { await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toBe(generic); }); + + it("patches the additive payload without a technician id and without PUT", async () => { + apiPatch.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } }); + + await vendorCompanyRosterApi.addTechnicians(5, { + rowVersion: "rv-1", + addTechnicians: [ + { + id: 99, + contactName: "Adam", + phone: "3145550198", + email: "", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: true, + }, + ], + }); + + expect(apiPatch).toHaveBeenCalledWith( + API_PATHS.vendorCompanyRoster.byCompany(5), + expect.objectContaining({ + rowVersion: "rv-1", + addTechnicians: [expect.objectContaining({ contactName: "Adam", phone: "(314) 555-0198" })], + }), + ); + const body = apiPatch.mock.calls[0]?.[1] as Record; + expect(body).not.toHaveProperty("companyFields"); + expect((body.addTechnicians as Array>)[0]).not.toHaveProperty("id"); + expect(apiPut).not.toHaveBeenCalled(); + expect(apiPost).not.toHaveBeenCalled(); + }); + + it("includes companyFields on the additive patch when provided", async () => { + apiPatch.mockResolvedValueOnce({ data: { companyId: 5, name: "Solo Co" } }); + + await vendorCompanyRosterApi.addTechnicians(5, { + rowVersion: "rv-2", + addTechnicians: [ + { contactName: "Adam", phone: "", email: "", tradeSpecialties: "", isActive: true }, + ], + companyFields: { notes: "Updated notes" }, + }); + + expect(apiPatch).toHaveBeenCalledWith( + API_PATHS.vendorCompanyRoster.byCompany(5), + expect.objectContaining({ + rowVersion: "rv-2", + companyFields: { notes: "Updated notes" }, + }), + ); + }); + + it("throws a stale conflict on a 409 from the additive patch", async () => { + apiPatch.mockRejectedValueOnce(httpError(409, { message: "rowversion mismatch" })); + + await expect( + vendorCompanyRosterApi.addTechnicians(5, { + rowVersion: "rv-1", + addTechnicians: [ + { contactName: "Adam", phone: "", email: "", tradeSpecialties: "", isActive: true }, + ], + }), + ).rejects.toSatisfy((error: unknown) => { + if (!isVendorRosterConflictError(error)) return false; + return error.conflict.kind === "stale"; + }); + }); }); diff --git a/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx new file mode 100644 index 00000000..61c431e6 --- /dev/null +++ b/src/test/domain/vendors/use-cases/use-save-vendor-company-roster-additive.test.tsx @@ -0,0 +1,234 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rosterAdd = vi.fn(); +const rosterUpdate = vi.fn(); +const rosterCreate = vi.fn(); + +vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ + vendorCompanyRosterApi: { + addTechnicians: (...args: unknown[]) => rosterAdd(...args), + update: (...args: unknown[]) => rosterUpdate(...args), + create: (...args: unknown[]) => rosterCreate(...args), + }, +})); + +vi.mock("@/domain/vendors/api/vendors-api", () => ({ + vendorsApi: { update: vi.fn() }, +})); + +vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { + buildAdditiveRosterPatch, + useSaveVendorCompanyRoster, +} from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; + +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: "Preferred vendor", + technicians: [ + { + id: 11, + contactName: "Ray Holt", + phone: "(314) 555-0122", + email: "", + preferredContact: "Phone", + tradeSpecialties: "Plumbing", + isActive: true, + totalJobs: 0, + }, + ], +}; + +const newTechnician = { + contactName: "Dana Kim", + phone: "(314) 555-0144", + email: "dana@test.test", + preferredContact: "Text" as const, + tradeSpecialties: "HVAC", + isActive: true, +}; + +function formValues( + overrides: Partial = {}, +): 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: [newTechnician], + ...overrides, + }; +} + +describe("buildAdditiveRosterPatch", () => { + it("carries only the newly entered technician, never a full roster snapshot", () => { + const patch = buildAdditiveRosterPatch(roster, formValues(), "rv-1"); + + expect(patch).not.toBeNull(); + expect(patch?.addTechnicians).toEqual([newTechnician]); + expect(patch?.addTechnicians[0]).not.toHaveProperty("id"); + }); + + it("ignores blank technician rows", () => { + const patch = buildAdditiveRosterPatch( + roster, + formValues({ + technicians: [ + { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, + }, + newTechnician, + ], + }), + "rv-1", + ); + + expect(patch?.addTechnicians).toEqual([newTechnician]); + }); + + it("sends only changed company fields", () => { + const patch = buildAdditiveRosterPatch( + roster, + formValues({ notes: "New notes", address: "2 Oak Ave" }), + "rv-1", + ); + + expect(patch?.companyFields).toEqual({ notes: "New notes", address: "2 Oak Ave" }); + }); + + it("omits companyFields entirely when nothing changed", () => { + const patch = buildAdditiveRosterPatch(roster, formValues(), "rv-1"); + + expect(patch).not.toHaveProperty("companyFields"); + }); + + it("returns null when there is nothing to add or change", () => { + const patch = buildAdditiveRosterPatch( + roster, + formValues({ + technicians: [ + { + contactName: "", + phone: "", + email: "", + preferredContact: "Phone", + tradeSpecialties: "", + isActive: true, + }, + ], + }), + "rv-1", + ); + + expect(patch).toBeNull(); + }); +}); + +function createWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useSaveVendorCompanyRoster routing", () => { + beforeEach(() => { + rosterAdd.mockReset(); + rosterUpdate.mockReset(); + rosterCreate.mockReset(); + }); + + it("add mode issues the additive patch and never the reconcile PUT", async () => { + rosterAdd.mockResolvedValueOnce(roster); + const { result } = renderHook(() => useSaveVendorCompanyRoster(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.mutate({ + mode: "add", + values: formValues(), + companyId: 5, + rowVersion: "rv-1", + originalRoster: roster, + }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(rosterAdd).toHaveBeenCalledTimes(1); + expect(rosterAdd).toHaveBeenCalledWith( + 5, + expect.objectContaining({ + rowVersion: "rv-1", + addTechnicians: [newTechnician], + }), + ); + expect(rosterUpdate).not.toHaveBeenCalled(); + expect(rosterCreate).not.toHaveBeenCalled(); + }); + + it("update mode still issues the full reconcile PUT", async () => { + rosterUpdate.mockResolvedValueOnce(roster); + const { result } = renderHook(() => useSaveVendorCompanyRoster(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.mutate({ + mode: "update", + values: formValues({ + technicians: [{ id: 11, ...newTechnician }, newTechnician], + }), + companyId: 5, + rowVersion: "rv-1", + originalRoster: roster, + }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(rosterUpdate).toHaveBeenCalledWith(5, expect.anything(), "rv-1"); + expect(rosterAdd).not.toHaveBeenCalled(); + }); + + it("create mode still issues the POST", async () => { + rosterCreate.mockResolvedValueOnce(roster); + const { result } = renderHook(() => useSaveVendorCompanyRoster(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.mutate({ mode: "create", values: formValues() }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(rosterCreate).toHaveBeenCalledTimes(1); + expect(rosterAdd).not.toHaveBeenCalled(); + expect(rosterUpdate).not.toHaveBeenCalled(); + }); +});