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(); + }); +});