mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
fix(work-orders): save wizard vendor notes to the vendor company
This commit is contained in:
parent
fc3e7591dc
commit
31982f9baa
8 changed files with 294 additions and 5 deletions
|
|
@ -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<WorkOrderWizardDraft>) => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<VendorCompanyNotesBaseline | null>(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 };
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ export function WizardStepVendorTime({
|
|||
optional
|
||||
onClear={
|
||||
draft.vendorId !== ""
|
||||
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" })
|
||||
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
26
src/domain/vendors/use-cases/use-save-vendor-company-notes.ts
vendored
Normal file
26
src/domain/vendors/use-cases/use-save-vendor-company-notes.ts
vendored
Normal file
|
|
@ -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.");
|
||||
},
|
||||
});
|
||||
}
|
||||
35
src/domain/vendors/utils/build-company-notes-patch.ts
vendored
Normal file
35
src/domain/vendors/utils/build-company-notes-patch.ts
vendored
Normal file
|
|
@ -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 } },
|
||||
};
|
||||
}
|
||||
|
|
@ -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<WorkOrderDetail, Error, WorkOrderWizardDraft> {
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderWizardDraft>({ ...EMPTY_WIZARD_DRAFT });
|
||||
const notes = useWizardVendorCompanyNotes(true, draft.vendorId, setDraft);
|
||||
return { draft, setDraft, ...notes };
|
||||
}
|
||||
|
||||
function renderHarness() {
|
||||
const client = createTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
31
src/test/domain/vendors/utils/build-company-notes-patch.test.ts
vendored
Normal file
31
src/test/domain/vendors/utils/build-company-notes-patch.test.ts
vendored
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue