From 31982f9baa99aff9a9fbedd74722729f21b67505 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:17:02 -0300 Subject: [PATCH 1/4] fix(work-orders): save wizard vendor notes to the vendor company --- .../wizard/use-new-wo-wizard-controller.ts | 12 +- .../wizard/use-wizard-vendor-company-notes.ts | 59 ++++++++ .../wizard/wizard-step-vendor-time.tsx | 2 +- .../use-save-vendor-company-notes.ts | 26 ++++ .../utils/build-company-notes-patch.ts | 35 +++++ .../use-create-work-order-from-wizard.ts | 6 +- .../use-wizard-vendor-company-notes.test.tsx | 128 ++++++++++++++++++ .../utils/build-company-notes-patch.test.ts | 31 +++++ 8 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts create mode 100644 src/domain/vendors/use-cases/use-save-vendor-company-notes.ts create mode 100644 src/domain/vendors/utils/build-company-notes-patch.ts create mode 100644 src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx create mode 100644 src/test/domain/vendors/utils/build-company-notes-patch.test.ts 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 908c6d1a..0f249909 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 @@ -16,6 +16,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"; type UseNewWoWizardControllerArgs = { open: boolean; @@ -50,6 +51,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) { @@ -57,7 +64,10 @@ export function useNewWoWizardController({ } }; - const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + const createMutation = useCreateWorkOrderFromWizard((createdDraft) => { + saveCompanyNotes(createdDraft); + onOpenChange(false); + }); 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..03db91d0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import { useSaveVendorCompanyNotes } from "@/domain/vendors/use-cases/use-save-vendor-company-notes"; +import { + buildCompanyNotesUpdate, + type VendorCompanyNotesBaseline, +} from "@/domain/vendors/utils/build-company-notes-patch"; + +type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void; + +/** + * SH-321: 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. + */ +export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, setDraft: SetDraft) { + 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 && String(baseline.companyId) === String(roster.companyId)) { + return; + } + setBaseline({ + companyId: roster.companyId, + rowVersion: roster.rowVersion, + notes: roster.notes, + }); + setDraft((current) => ({ ...current, vendorNotes: roster.notes })); + }, [vendorId, roster, baseline, setDraft]); + + function saveCompanyNotes(draft: WorkOrderWizardDraft) { + if (!draft.vendorId) { + return; + } + const update = buildCompanyNotesUpdate(baseline, draft.vendorNotes); + if (update) { + saveNotes.mutate(update); + } + } + + return { saveCompanyNotes }; +} 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..230f18c3 --- /dev/null +++ b/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts @@ -0,0 +1,26 @@ +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"; + +/** Writes company-level vendor notes (SH-321) 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("Work order created, but the vendor notes could not be saved."); + }, + }); +} 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..dc2de725 --- /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; +} + +/** + * SH-321: 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 205f010e..3106ea65 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 @@ -11,19 +11,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..924e766e --- /dev/null +++ b/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx @@ -0,0 +1,128 @@ +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 { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +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() } })); + +function roster(companyId: number, notes: string) { + return { + companyId, + rowVersion: `rv-${companyId}`, + name: `Company ${companyId}`, + companyPhone: "", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes, + technicians: [], + }; +} + +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 (SH-321)", () => { + beforeEach(() => { + rosterGet.mockReset(); + addTechnicians.mockReset(); + 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("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")); + + act(() => + result.current.saveCompanyNotes({ ...result.current.draft, vendorNotes: "Gate code 4411" }), + ); + + await waitFor(() => + 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")); + + act(() => result.current.saveCompanyNotes(result.current.draft)); + + expect(addTechnicians).not.toHaveBeenCalled(); + }); +}); 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..11769b1e --- /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 (SH-321)", () => { + 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(); + }); +}); From 7c090571c5ae1387e48f44d51b76db5e1e6a8523 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 13:20:32 -0300 Subject: [PATCH 2/4] test(work-orders): cover wizard vendor company notes journey --- .../wizard-vendor-company-notes.spec.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 e2e/work-orders/wizard-vendor-company-notes.spec.ts 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, + ); + }); +}); From cd5d4ad1d872cec2a3a823457cd611234594e841 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 15:18:06 -0300 Subject: [PATCH 3/4] fix(work-orders): keep wizard vendor notes bound to the selected company Late roster data no longer overwrites notes typed for the current selection. The notes baseline is keyed by vendor, so a save never patches a previous company. Changing the primary service clears leftover notes, and a save with a missing baseline reads the current roster first. Also drops ticket keys from shipped comments. Co-Authored-By: Claude Opus 5 (1M context) --- .../wizard/use-new-wo-wizard-controller.ts | 5 +- .../wizard/use-wizard-vendor-company-notes.ts | 83 ++++++++++-- .../wizard-step-location-service-select.tsx | 2 +- .../use-save-vendor-company-notes.ts | 7 +- .../utils/build-company-notes-patch.ts | 2 +- .../use-wizard-vendor-company-notes.test.tsx | 118 ++++++++++++++++-- .../wizard-service-clears-vendor.test.tsx | 4 + .../utils/build-company-notes-patch.test.ts | 2 +- 8 files changed, 193 insertions(+), 30 deletions(-) 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 0f249909..9e0476d3 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 @@ -65,8 +65,9 @@ export function useNewWoWizardController({ }; const createMutation = useCreateWorkOrderFromWizard((createdDraft) => { - saveCompanyNotes(createdDraft); - onOpenChange(false); + // Close only after the company-notes save attempt settles; its failures are surfaced + // as note-save warnings and must never report the work-order creation as failed. + void saveCompanyNotes(createdDraft).finally(() => onOpenChange(false)); }); const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } = useWizardDuplicateActions({ 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 index 03db91d0..73625b6a 100644 --- 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 @@ -1,7 +1,14 @@ 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 { useSaveVendorCompanyNotes } from "@/domain/vendors/use-cases/use-save-vendor-company-notes"; +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, @@ -9,15 +16,21 @@ import { type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void; +type VendorNotesBaseline = VendorCompanyNotesBaseline & { vendorId: string }; + /** - * SH-321: 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. + * 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); + const [baseline, setBaseline] = useState(null); useEffect(() => { if (open) { @@ -34,24 +47,72 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set if (!roster || roster.companyId === null) { return; } - if (baseline && String(baseline.companyId) === String(roster.companyId)) { + 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) => ({ ...current, vendorNotes: 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]); - function saveCompanyNotes(draft: WorkOrderWizardDraft) { + 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 update = buildCompanyNotesUpdate(baseline, draft.vendorNotes); - if (update) { - saveNotes.mutate(update); + 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. } } 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 bfefcf5d..b121d3f1 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) => { onPatch({ 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 index 230f18c3..9e98976b 100644 --- a/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts +++ b/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts @@ -5,7 +5,10 @@ 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"; -/** Writes company-level vendor notes (SH-321) and refreshes every vendor view that shows them. */ +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, @@ -20,7 +23,7 @@ export function useSaveVendorCompanyNotes(): UseMutationResult< void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); }, onError: () => { - toast.error("Work order created, but the vendor notes could not be saved."); + 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 index dc2de725..3546a3cc 100644 --- a/src/domain/vendors/utils/build-company-notes-patch.ts +++ b/src/domain/vendors/utils/build-company-notes-patch.ts @@ -12,7 +12,7 @@ export interface VendorCompanyNotesUpdate { } /** - * SH-321: company-level notes edited outside the Vendors page (e.g. the Create WO wizard) + * 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. 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 index 924e766e..8801c765 100644 --- 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 @@ -3,10 +3,12 @@ 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(); @@ -21,6 +23,8 @@ vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +type Roster = ReturnType; + function roster(companyId: number, notes: string) { return { companyId, @@ -38,6 +42,17 @@ function roster(companyId: number, notes: string) { }; } +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); @@ -52,10 +67,11 @@ function renderHarness() { return renderHook(() => useHarness(), { wrapper }); } -describe("useWizardVendorCompanyNotes (SH-321)", () => { +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"), @@ -98,22 +114,98 @@ describe("useWizardVendorCompanyNotes (SH-321)", () => { 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")); - act(() => - result.current.saveCompanyNotes({ ...result.current.draft, vendorNotes: "Gate code 4411" }), - ); + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); - await waitFor(() => - expect(addTechnicians).toHaveBeenCalledWith(7, { - rowVersion: "rv-7", - addTechnicians: [], - companyFields: { notes: "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 () => { @@ -121,7 +213,9 @@ describe("useWizardVendorCompanyNotes (SH-321)", () => { act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); - act(() => result.current.saveCompanyNotes(result.current.draft)); + 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 3812fb63..9a22a49a 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( @@ -83,6 +86,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 index 11769b1e..824ad1c8 100644 --- a/src/test/domain/vendors/utils/build-company-notes-patch.test.ts +++ b/src/test/domain/vendors/utils/build-company-notes-patch.test.ts @@ -3,7 +3,7 @@ import { buildCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-no const baseline = { companyId: 12, rowVersion: "AAAAAAAAB9E=", notes: "Call before arrival" }; -describe("buildCompanyNotesUpdate (SH-321)", () => { +describe("buildCompanyNotesUpdate", () => { it("builds a notes-only additive patch when the notes changed", () => { expect(buildCompanyNotesUpdate(baseline, " Gate code 4411 ")).toEqual({ companyId: 12, From 1fa9f6ec02971cc9b1c53d31ba8dcae44b47c2b9 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 19:29:29 -0300 Subject: [PATCH 4/4] fix(work-orders): close wizard before saving vendor notes on create The create mutation's onSuccess awaited saveCompanyNotes before calling onOpenChange(false). Because isCreating is createMutation.isPending || isCheckingDuplicate, and isPending drops to false the moment the work-order create resolves, the dialog stayed open with Create re-enabled and pointer events unlocked for the length of the roster GET plus notes PATCH. A second Create click ran handleCreate again; with a blank provisional WO number the duplicate lookup short-circuits without an API call, so createMutation.mutate fired a second time and created a duplicate work order. Close the dialog first, then fire saveCompanyNotes unawaited. The save does not need the dialog open: fetchCurrentBaseline reads the roster when the baseline is missing, and the failure toast is raised from the mutation's option-level onError, so it still surfaces after the dialog unmounts. --- .../_components/wizard/use-new-wo-wizard-controller.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 9e0476d3..cb988a51 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 @@ -65,9 +65,13 @@ export function useNewWoWizardController({ }; const createMutation = useCreateWorkOrderFromWizard((createdDraft) => { - // Close only after the company-notes save attempt settles; its failures are surfaced - // as note-save warnings and must never report the work-order creation as failed. - void saveCompanyNotes(createdDraft).finally(() => onOpenChange(false)); + // 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({