diff --git a/e2e/work-orders/wizard-vendor-company-notes.spec.ts b/e2e/work-orders/wizard-vendor-company-notes.spec.ts new file mode 100644 index 00000000..df507616 --- /dev/null +++ b/e2e/work-orders/wizard-vendor-company-notes.spec.ts @@ -0,0 +1,168 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wizard-vendor-notes-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +const LOCATIONS = [ + { + id: 10, + name: "STL-01 · St. Louis, MO", + siteCode: "STL-01", + city: "St. Louis", + state: "MO", + code: "STL-01", + }, +]; + +const VENDORS = [ + { + id: 101, + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + tradeSpecialties: "Plumbing", + address: "1 Market St", + }, +]; + +type Captured = { method: string; path: string; body: unknown }; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function mockApi(page: Page, captured: Captured[]) { + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const request = route.request(); + const { pathname } = new URL(request.url()); + if (request.method() !== "GET") { + captured.push({ method: request.method(), path: pathname, body: request.postDataJSON() }); + } + if (pathname.endsWith("/workorders/board") && request.method() === "POST") { + await fulfillJson(route, { data: { id: 900, woNumber: "SH00900" } }); + return; + } + if (pathname.endsWith("/vendor-company-roster/55") && request.method() === "PATCH") { + await fulfillJson(route, { + companyId: 55, + rowVersion: "rv-2", + name: "Gateway Plumbing", + notes: "Gate code 4411", + technicians: [], + }); + return; + } + if (pathname.endsWith("/vendor-company-roster") && request.method() === "GET") { + await fulfillJson(route, { + companyId: 55, + rowVersion: "rv-1", + name: "Gateway Plumbing", + notes: "Call before arrival", + technicians: [{ id: 101, contactName: "Adam Whyte", isActive: true }], + }); + return; + } + if (pathname.includes("workorders/board")) { + await fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 0, total: 0 }, + unscheduled: [], + scheduled: [], + items: [], + }); + return; + } + if (pathname.endsWith("/locations/10")) { + await fulfillJson(route, { + data: { + id: 10, + name: "STL-01 · St. Louis, MO", + contact: "Jamie Site", + phone: "3145550100", + zipCode: "63101", + }, + }); + return; + } + if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) { + await fulfillJson(route, { data: LOCATIONS }); + return; + } + if (pathname.toLowerCase().includes("/vendors/dropdown")) { + await fulfillJson(route, { data: VENDORS }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("wizard vendor company notes (SH-321)", () => { + test.setTimeout(60_000); + + test("pre-fills the company notes and saves edits to the vendor company on create", async ({ + page, + }) => { + const captured: Captured[] = []; + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await mockApi(page, captured); + + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /Select site/ }).click(); + await page.getByRole("button", { name: "STL-01 · St. Louis, MO" }).first().click(); + await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /Select technician/ }).click(); + await page + .getByRole("button", { name: /Adam Whyte/ }) + .last() + .first() + .click(); + + const notes = page.getByPlaceholder("Contact preferences, availability, etc."); + await expect(notes).toHaveValue("Call before arrival"); + await notes.fill("Gate code 4411"); + + await page.getByRole("button", { name: /Create/ }).click(); + + await expect + .poll(() => captured.find((c) => c.method === "PATCH")) + .toEqual({ + method: "PATCH", + path: "/api/vendor-company-roster/55", + body: { + rowVersion: "rv-1", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }, + }); + expect(captured.some((c) => c.method === "POST" && c.path.endsWith("/workorders/board"))).toBe( + true, + ); + }); +}); diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts index 0a5f394b..17069cc9 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts @@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft"; import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions"; import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; +import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes"; import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type"; type UseNewWoWizardControllerArgs = { @@ -49,6 +50,12 @@ export function useNewWoWizardController({ draftState.setDraft, ); + const { saveCompanyNotes } = useWizardVendorCompanyNotes( + open, + draftState.draft.vendorId, + draftState.setDraft, + ); + const patchDraft = (patch: Partial) => { draftState.patchDraft(patch); if (patch.locationId !== undefined) { @@ -56,7 +63,15 @@ export function useNewWoWizardController({ } }; - const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + const createMutation = useCreateWorkOrderFromWizard((createdDraft) => { + // Close the dialog first: the work-order create's isPending has already dropped, so + // holding the dialog open through the notes save would re-enable Create and unlock + // pointer events, and a second click would create a second work order. The save runs + // independently of the dialog — fetchCurrentBaseline covers a missing baseline and the + // note-save failure toast is surfaced from the mutation itself, not the dialog. + onOpenChange(false); + void saveCompanyNotes(createdDraft); + }); const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } = useWizardDuplicateActions({ open, diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts new file mode 100644 index 00000000..73625b6a --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import { + NOTES_SAVE_FAILED_MESSAGE, + useSaveVendorCompanyNotes, +} from "@/domain/vendors/use-cases/use-save-vendor-company-notes"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { + buildCompanyNotesUpdate, + type VendorCompanyNotesBaseline, +} from "@/domain/vendors/utils/build-company-notes-patch"; + +type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void; + +type VendorNotesBaseline = VendorCompanyNotesBaseline & { vendorId: string }; + +/** + * The wizard's Vendor Notes field is the vendor company's notes (same field as the Vendors + * page). Picking a technician pre-fills the company's current notes once per company, so + * switching technicians within the same company keeps the dispatcher's edits. Notes typed + * for the current selection always win over roster data that arrives late, and a save whose + * baseline is missing or belongs to another selection reads the current roster before + * building the PATCH. + */ +export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, setDraft: SetDraft) { + const queryClient = useQueryClient(); + const { data: roster } = useVendorCompanyRoster(vendorId || undefined); + const saveNotes = useSaveVendorCompanyNotes(); + const [baseline, setBaseline] = useState(null); + + useEffect(() => { + if (open) { + setBaseline(null); + } + }, [open]); + + useEffect(() => { + if (!vendorId) { + // Cleared technician: the next pick re-seeds even for the same company. + if (baseline) setBaseline(null); + return; + } + if (!roster || roster.companyId === null) { + return; + } + if ( + baseline && + baseline.vendorId === vendorId && + String(baseline.companyId) === String(roster.companyId) + ) { + return; + } + setBaseline({ + vendorId, + companyId: roster.companyId, + rowVersion: roster.rowVersion, + notes: roster.notes, + }); + setDraft((current) => { + if (current.vendorId !== vendorId) { + return current; + } + // Seed only empty content or content still holding the previous company's unedited + // notes; anything else is the dispatcher's typing and must never be overwritten. + if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) { + return current; + } + return { ...current, vendorNotes: roster.notes }; + }); + }, [vendorId, roster, baseline, setDraft]); + + async function fetchCurrentBaseline(vendorId: string): Promise { + try { + const current = await queryClient.fetchQuery({ + queryKey: queryKeys.vendors.roster("vendorId", vendorId), + queryFn: () => vendorCompanyRosterApi.get({ vendorId }), + }); + if (!current || current.companyId === null) { + return null; + } + return { + vendorId, + companyId: current.companyId, + rowVersion: current.rowVersion, + notes: current.notes, + }; + } catch { + toast.error(NOTES_SAVE_FAILED_MESSAGE); + return null; + } + } + + async function saveCompanyNotes(draft: WorkOrderWizardDraft): Promise { + if (!draft.vendorId) { + return; + } + const active = + baseline && baseline.vendorId === draft.vendorId + ? baseline + : await fetchCurrentBaseline(draft.vendorId); + if (!active) { + return; + } + const update = buildCompanyNotesUpdate(active, draft.vendorNotes); + if (!update) { + return; + } + try { + await saveNotes.mutateAsync(update); + } catch { + // The mutation already surfaces the note-save failure toast. + } + } + + return { saveCompanyNotes }; +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx index baadd66e..8b15610d 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx @@ -29,7 +29,7 @@ export function WizardStepLocationServiceSelect({ onPatch, }: WizardStepLocationServiceSelectProps) { const clearVendorIfPrimaryChanged = (nextPm: string): Partial => - nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; + nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {}; const selectSvc = (p: string) => { const selected = services.find((service) => service.value === p); diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index 1fbd6142..1d052b45 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -206,7 +206,7 @@ export function WizardStepVendorTime({ optional onClear={ draft.vendorId !== "" - ? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" }) + ? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" }) : undefined } > diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts b/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts new file mode 100644 index 00000000..9e98976b --- /dev/null +++ b/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts @@ -0,0 +1,29 @@ +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 type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-notes-patch"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export const NOTES_SAVE_FAILED_MESSAGE = + "Work order created, but the vendor notes could not be saved."; + +/** Writes company-level vendor notes and refreshes every vendor view that shows them. */ +export function useSaveVendorCompanyNotes(): UseMutationResult< + VendorCompanyRoster, + unknown, + VendorCompanyNotesUpdate +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ companyId, patch }: VendorCompanyNotesUpdate) => + vendorCompanyRosterApi.addTechnicians(companyId, patch), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); + }, + onError: () => { + toast.error(NOTES_SAVE_FAILED_MESSAGE); + }, + }); +} diff --git a/src/domain/vendors/utils/build-company-notes-patch.ts b/src/domain/vendors/utils/build-company-notes-patch.ts new file mode 100644 index 00000000..3546a3cc --- /dev/null +++ b/src/domain/vendors/utils/build-company-notes-patch.ts @@ -0,0 +1,35 @@ +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper"; + +export type VendorCompanyNotesBaseline = Pick< + VendorCompanyRoster, + "companyId" | "rowVersion" | "notes" +>; + +export interface VendorCompanyNotesUpdate { + companyId: string | number; + patch: VendorRosterAdditivePatch; +} + +/** + * Company-level notes edited outside the Vendors page (e.g. the Create WO wizard) + * are written through the additive roster PATCH, which only touches the fields sent. + * Returns null when nothing changed. Clearing notes is not expressible on that PATCH + * (blank company fields mean "unchanged"), so an emptied field is not sent. + */ +export function buildCompanyNotesUpdate( + baseline: VendorCompanyNotesBaseline | null | undefined, + editedNotes: string, +): VendorCompanyNotesUpdate | null { + if (!baseline || baseline.companyId === null || !baseline.rowVersion) { + return null; + } + const notes = editedNotes.trim(); + if (!notes || notes === baseline.notes.trim()) { + return null; + } + return { + companyId: baseline.companyId, + patch: { rowVersion: baseline.rowVersion, addTechnicians: [], companyFields: { notes } }, + }; +} diff --git a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts index 4aa4c8b8..5e4ad7c9 100644 --- a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts +++ b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts @@ -13,19 +13,19 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order import { queryKeys } from "@/infra/query-key/query-key"; export function useCreateWorkOrderFromWizard( - onSuccess?: () => void, + onSuccess?: (draft: WorkOrderWizardDraft) => void, ): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft), - onSuccess: () => { + onSuccess: (_created, draft) => { void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); toast.success("Work Order created successfully!"); - onSuccess?.(); + onSuccess?.(draft); }, onError: (error: Error) => { diff --git a/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx b/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx new file mode 100644 index 00000000..8801c765 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx @@ -0,0 +1,222 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes"; +import { NOTES_SAVE_FAILED_MESSAGE } from "@/domain/vendors/use-cases/use-save-vendor-company-notes"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { toast } from "react-toastify"; +import { createTestQueryClient } from "@/test/test-utils"; + +const rosterGet = vi.fn(); +const addTechnicians = vi.fn(); + +vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ + vendorCompanyRosterApi: { + get: (...args: unknown[]) => rosterGet(...args), + addTechnicians: (...args: unknown[]) => addTechnicians(...args), + }, +})); + +vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +type Roster = ReturnType; + +function roster(companyId: number, notes: string) { + return { + companyId, + rowVersion: `rv-${companyId}`, + name: `Company ${companyId}`, + companyPhone: "", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes, + technicians: [], + }; +} + +function deferredRoster() { + let resolve!: (value: Roster) => void; + const pending = new Promise((res) => { + resolve = res; + }); + return { + promise: () => pending, + resolveAll: (value: Roster) => resolve(value), + }; +} + +function useHarness() { + const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT }); + const notes = useWizardVendorCompanyNotes(true, draft.vendorId, setDraft); + return { draft, setDraft, ...notes }; +} + +function renderHarness() { + const client = createTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useHarness(), { wrapper }); +} + +describe("useWizardVendorCompanyNotes", () => { + beforeEach(() => { + rosterGet.mockReset(); + addTechnicians.mockReset(); + vi.mocked(toast.error).mockClear(); + rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) => + Promise.resolve( + vendorId === "31" ? roster(9, "Other company notes") : roster(7, "Call before arrival"), + ), + ); + addTechnicians.mockResolvedValue(roster(7, "Gate code 4411")); + }); + + it("pre-fills the Notes field with the picked technician's company notes", async () => { + const { result } = renderHarness(); + + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + expect(rosterGet).toHaveBeenCalledWith({ vendorId: "21", companyId: undefined }); + }); + + it("keeps edits when switching technicians within the same company", async () => { + rosterGet.mockResolvedValue(roster(7, "Call before arrival")); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "22" }))); + + await waitFor(() => + expect(rosterGet).toHaveBeenCalledWith({ vendorId: "22", companyId: undefined }), + ); + expect(result.current.draft.vendorNotes).toBe("Gate code 4411"); + }); + + it("re-seeds notes when the technician belongs to a different company", async () => { + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "31" }))); + + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Other company notes")); + }); + + it("keeps notes typed before the roster resolves and saves them for that company", async () => { + const lateRoster = deferredRoster(); + rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) => + vendorId === "21" ? lateRoster.promise() : Promise.resolve(roster(7, "Call before arrival")), + ); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + + await act(async () => lateRoster.resolveAll(roster(7, "Call before arrival"))); + expect(result.current.draft.vendorNotes).toBe("Gate code 4411"); + + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); + + expect(addTechnicians).toHaveBeenCalledWith(7, { + rowVersion: "rv-7", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }); + }); + + it("targets the newly selected company at save time while its roster is still loading", async () => { + const companyB = deferredRoster(); + rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) => + vendorId === "21" ? Promise.resolve(roster(7, "Call before arrival")) : companyB.promise(), + ); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "31" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + + let savePromise!: Promise; + act(() => { + savePromise = result.current + .saveCompanyNotes({ ...result.current.draft, vendorNotes: "Gate code 4411" }) + .then(() => undefined); + }); + await act(async () => companyB.resolveAll(roster(9, "Other company notes"))); + await act(async () => { + await savePromise; + }); + + expect(addTechnicians).toHaveBeenCalledWith(9, { + rowVersion: "rv-9", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }); + expect(addTechnicians).not.toHaveBeenCalledWith(7, expect.anything()); + expect(result.current.draft.vendorNotes).toBe("Gate code 4411"); + }); + + it("warns without writing when the save-time roster read fails", async () => { + rosterGet.mockRejectedValue(new Error("roster down")); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); + + expect(toast.error).toHaveBeenCalledWith(NOTES_SAVE_FAILED_MESSAGE); + expect(addTechnicians).not.toHaveBeenCalled(); + }); + + it("writes edited notes to the vendor company when the work order is created", async () => { + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); + + expect(addTechnicians).toHaveBeenCalledWith(7, { + rowVersion: "rv-7", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }); + }); + + it("does not write when the notes were left as the company's existing notes", async () => { + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + await act(async () => { + await result.current.saveCompanyNotes(result.current.draft); + }); + + expect(addTechnicians).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx index c46f95df..30658510 100644 --- a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx @@ -15,6 +15,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "9", vendorName: "Old Vendor", techPhone: "555-0100", + vendorNotes: "Old company notes", }; render( @@ -43,6 +44,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "", vendorName: "", techPhone: "", + vendorNotes: "", }), ); }); @@ -56,6 +58,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "9", vendorName: "Old Vendor", techPhone: "555-0100", + vendorNotes: "Old company notes", }; render( @@ -84,6 +87,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "", vendorName: "", techPhone: "", + vendorNotes: "", }); }); }); diff --git a/src/test/domain/vendors/utils/build-company-notes-patch.test.ts b/src/test/domain/vendors/utils/build-company-notes-patch.test.ts new file mode 100644 index 00000000..824ad1c8 --- /dev/null +++ b/src/test/domain/vendors/utils/build-company-notes-patch.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-notes-patch"; + +const baseline = { companyId: 12, rowVersion: "AAAAAAAAB9E=", notes: "Call before arrival" }; + +describe("buildCompanyNotesUpdate", () => { + it("builds a notes-only additive patch when the notes changed", () => { + expect(buildCompanyNotesUpdate(baseline, " Gate code 4411 ")).toEqual({ + companyId: 12, + patch: { + rowVersion: "AAAAAAAAB9E=", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }, + }); + }); + + it("skips the write when the notes are unchanged apart from whitespace", () => { + expect(buildCompanyNotesUpdate(baseline, "Call before arrival ")).toBeNull(); + }); + + it("skips the write without a loaded company baseline", () => { + expect(buildCompanyNotesUpdate(null, "New notes")).toBeNull(); + expect(buildCompanyNotesUpdate({ ...baseline, companyId: null }, "New notes")).toBeNull(); + expect(buildCompanyNotesUpdate({ ...baseline, rowVersion: "" }, "New notes")).toBeNull(); + }); + + it("does not send an emptied field, which the additive patch treats as unchanged", () => { + expect(buildCompanyNotesUpdate(baseline, " ")).toBeNull(); + }); +});