mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 16:32:06 +00:00
Merge remote-tracking branch 'origin/dev' into fix/ab/sh-364-inline-technician-registration
# Conflicts: # src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts # src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx # src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx # src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx
This commit is contained in:
commit
abe28a3a7f
12 changed files with 647 additions and 7 deletions
174
e2e/work-orders/wizard-vendor-company-notes.spec.ts
Normal file
174
e2e/work-orders/wizard-vendor-company-notes.spec.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
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 company/ }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /Gateway Plumbing/ })
|
||||||
|
.first()
|
||||||
|
.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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,7 @@ import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wi
|
||||||
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
|
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 { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
|
||||||
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
|
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
|
||||||
|
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";
|
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
|
||||||
|
|
||||||
type UseNewWoWizardControllerArgs = {
|
type UseNewWoWizardControllerArgs = {
|
||||||
|
|
@ -50,6 +51,12 @@ export function useNewWoWizardController({
|
||||||
draftState.setDraft,
|
draftState.setDraft,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { saveCompanyNotes } = useWizardVendorCompanyNotes(
|
||||||
|
open,
|
||||||
|
draftState.draft.vendorId,
|
||||||
|
draftState.setDraft,
|
||||||
|
);
|
||||||
|
|
||||||
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||||
draftState.patchDraft(patch);
|
draftState.patchDraft(patch);
|
||||||
if (patch.locationId !== undefined) {
|
if (patch.locationId !== undefined) {
|
||||||
|
|
@ -57,7 +64,15 @@ export function useNewWoWizardController({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWorkOrder = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
const createWorkOrder = 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 createMutation = useWizardCreateWithTechnicians({
|
const createMutation = useWizardCreateWithTechnicians({
|
||||||
vendors,
|
vendors,
|
||||||
patchDraft: draftState.patchDraft,
|
patchDraft: draftState.patchDraft,
|
||||||
|
|
|
||||||
|
|
@ -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<VendorNotesBaseline | 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 &&
|
||||||
|
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<VendorNotesBaseline | null> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,14 @@ export function WizardStepLocationServiceSelect({
|
||||||
}: WizardStepLocationServiceSelectProps) {
|
}: WizardStepLocationServiceSelectProps) {
|
||||||
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
|
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
|
||||||
nextPm !== draft.pm
|
nextPm !== draft.pm
|
||||||
? { vendorId: "", vendorName: "", techPhone: "", vendorCompany: "", newTechnicians: [] }
|
? {
|
||||||
|
vendorId: "",
|
||||||
|
vendorName: "",
|
||||||
|
techPhone: "",
|
||||||
|
vendorCompany: "",
|
||||||
|
vendorNotes: "",
|
||||||
|
newTechnicians: [],
|
||||||
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const selectSvc = (p: string) => {
|
const selectSvc = (p: string) => {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export function WizardVendorCompanyFields({
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
|
vendorNotes: "",
|
||||||
newTechnicians: [],
|
newTechnicians: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +81,7 @@ export function WizardVendorCompanyFields({
|
||||||
onPatch({
|
onPatch({
|
||||||
vendorCompany: nextCompany,
|
vendorCompany: nextCompany,
|
||||||
...(nextCompany !== company
|
...(nextCompany !== company
|
||||||
? { vendorId: "", vendorName: "", techPhone: "", newTechnicians: [] }
|
? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "", newTechnicians: [] }
|
||||||
: {}),
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +93,7 @@ export function WizardVendorCompanyFields({
|
||||||
optional
|
optional
|
||||||
onClear={
|
onClear={
|
||||||
draft.vendorId !== ""
|
draft.vendorId !== ""
|
||||||
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" })
|
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
29
src/domain/vendors/use-cases/use-save-vendor-company-notes.ts
vendored
Normal file
29
src/domain/vendors/use-cases/use-save-vendor-company-notes.ts
vendored
Normal file
|
|
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -13,19 +13,19 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useCreateWorkOrderFromWizard(
|
export function useCreateWorkOrderFromWizard(
|
||||||
onSuccess?: () => void,
|
onSuccess?: (draft: WorkOrderWizardDraft) => void,
|
||||||
): UseMutationResult<WorkOrderDetail, Error, WorkOrderWizardDraft> {
|
): UseMutationResult<WorkOrderDetail, Error, WorkOrderWizardDraft> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft),
|
mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft),
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: (_created, draft) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
|
||||||
toast.success("Work Order created successfully!");
|
toast.success("Work Order created successfully!");
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.(draft);
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
|
||||||
|
|
@ -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<typeof roster>;
|
||||||
|
|
||||||
|
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<Roster>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
promise: () => pending,
|
||||||
|
resolveAll: (value: Roster) => resolve(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
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<void>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
||||||
vendorId: "9",
|
vendorId: "9",
|
||||||
vendorName: "Old Vendor",
|
vendorName: "Old Vendor",
|
||||||
techPhone: "555-0100",
|
techPhone: "555-0100",
|
||||||
|
vendorNotes: "Old company notes",
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -44,6 +45,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
vendorCompany: "",
|
vendorCompany: "",
|
||||||
|
vendorNotes: "",
|
||||||
newTechnicians: [],
|
newTechnicians: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -58,6 +60,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
||||||
vendorId: "9",
|
vendorId: "9",
|
||||||
vendorName: "Old Vendor",
|
vendorName: "Old Vendor",
|
||||||
techPhone: "555-0100",
|
techPhone: "555-0100",
|
||||||
|
vendorNotes: "Old company notes",
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -87,6 +90,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
vendorCompany: "",
|
vendorCompany: "",
|
||||||
|
vendorNotes: "",
|
||||||
newTechnicians: [],
|
newTechnicians: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -127,6 +131,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
vendorCompany: "",
|
vendorCompany: "",
|
||||||
|
vendorNotes: "",
|
||||||
newTechnicians: [],
|
newTechnicians: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ describe("WizardStepVendorTime inline technician (SH-364)", () => {
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
vendorName: "",
|
vendorName: "",
|
||||||
techPhone: "",
|
techPhone: "",
|
||||||
|
vendorNotes: "",
|
||||||
newTechnicians: [],
|
newTechnicians: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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", () => {
|
||||||
|
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