Merge branch 'main' into feat/ab/sh-304-services-registry-ui

This commit is contained in:
Alexandre Brandizzi 2026-09-18 19:09:49 -03:00 • committed by GitHub
commit 7c1243eec3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1224 additions and 136 deletions

View file

@ -0,0 +1,265 @@
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
// Technicians registered inline from work-order entry points
// become real vendor records only on save, through the additive vendor-company roster
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-inline-technician-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 VENDORS = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
},
];
const ROSTER = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
vendorId: 41,
vendorName: "Vinewood LLC",
techName: "Adam Whyte",
title: "Quarterly HVAC PM",
rowVersion: "rv-1",
};
type Captured = { method: string; pathname: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function bodyOf(request: Request): unknown {
try {
return request.postDataJSON();
} catch {
return undefined;
}
}
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
const added = (body.addTechnicians ?? []).map((technician, index) => ({
...technician,
id: 42 + index,
}));
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(page: Page, writes: Captured[]) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
const method = request.method();
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
if (pathname.includes("/workorders/board/search")) {
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
}
if (pathname === "/api/workorders/board" && method === "GET") {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [BOARD_ROW],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
}
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: VENDORS });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
}
if (pathname === "/api/locations/10") {
return fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
}
return fulfillJson(route, { data: [] });
});
}
function vendorWrites(writes: Captured[]) {
return writes.filter(
(write) =>
write.pathname.startsWith("/api/vendors") ||
write.pathname.startsWith("/api/vendor-company-roster"),
);
}
async function openWizardVendorStep(page: Page) {
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select company/i }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await page.getByRole("button", { name: "+ Add technician" }).click();
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
await page.getByLabel("New Technician 1 phone").fill("3145550199");
}
test.describe("Inline technician registration", () => {
test("wizard persists the technician under the company on create and assigns it", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
// Staging a technician is local only.
expect(vendorWrites(writes)).toEqual([]);
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
.toBeTruthy();
expect(vendorWrites(writes)).toEqual([
{
method: "PATCH",
pathname: "/api/vendor-company-roster/7",
body: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
},
},
]);
const create = writes.find((write) => write.pathname === "/api/workorders/board");
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
// Reopening starts clean: the staged technician was discarded, not persisted.
await page.getByRole("button", { name: "New WO" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
expect(writes).toEqual([]);
});
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
await page
.locator("#wo-row-1")
.getByRole("button", { name: /Adam Whyte/ })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
await dialog.getByRole("button", { name: /^add$/i }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
.toBeTruthy();
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(JSON.stringify(boardPatch?.body)).toContain("42");
});
});

View file

@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => {
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/ })

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import {
buildCompanyOptions,
buildTechnicianOptions,
@ -37,7 +37,7 @@ export function useVendorDialogForm({
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const createVendor = useCreateVendor();
const addTechnicians = useAddTechniciansToCompany();
const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
@ -119,7 +119,7 @@ export function useVendorDialogForm({
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
addTechnicians,
onSave,
onOpenChange,
setSaveError,
@ -141,7 +141,7 @@ export function useVendorDialogForm({
companyVendor,
saveError,
canSubmit,
isPending: createVendor.isPending,
isPending: addTechnicians.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);

View file

@ -1,9 +1,8 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
anchorCompanyName: string;
tech: string;
phone: string;
createVendor: ReturnType<typeof useCreateVendor>;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
canSubmit: boolean;
};
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
async function persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
}: {
allVendors: VendorDropdownItem[];
pendingPayload: VendorFormValues;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
}): Promise<VendorDropdownItem> {
const anchor = allVendors.find(
(vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName,
);
if (!anchor) {
throw new Error("Select an existing vendor company before adding a technician.");
}
const [created] = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: [
{
contactName: pendingPayload.contactName,
phone: phone.trim() || (pendingPayload.phone ?? ""),
tradeSpecialties: pendingPayload.tradeSpecialties,
},
],
});
if (created == null) throw new Error("Failed to create technician");
return created;
}
export function useVendorDialogSave({
allVendors,
pendingCreates,
@ -30,7 +61,7 @@ export function useVendorDialogSave({
anchorCompanyName,
tech,
phone,
createVendor,
addTechnicians,
onSave,
onOpenChange,
setSaveError,
@ -53,11 +84,12 @@ export function useVendorDialogSave({
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
saveVendor = await persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);

View file

@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
import { 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";
@ -63,7 +64,7 @@ export function useNewWoWizardController({
}
};
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
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
@ -72,6 +73,11 @@ export function useNewWoWizardController({
onOpenChange(false);
void saveCompanyNotes(createdDraft);
});
const createMutation = useWizardCreateWithTechnicians({
vendors,
patchDraft: draftState.patchDraft,
createMutation: createWorkOrder,
});
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,

View file

@ -0,0 +1,93 @@
import { toast } from "react-toastify";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type {
WizardNewTechnician,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
type UseWizardCreateWithTechniciansArgs = {
vendors: VendorDropdownItem[];
patchDraft: (patch: Partial<WorkOrderWizardDraft>) => void;
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] {
return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== "");
}
/** Draft changes applied once inline technicians exist as real vendor records. */
export function buildCreatedTechniciansPatch(
draft: WorkOrderWizardDraft,
company: string,
pending: WizardNewTechnician[],
created: VendorDropdownItem[],
): Partial<WorkOrderWizardDraft> {
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
patch.vendorId = String(only.id);
patch.vendorName = company;
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
}
return patch;
}
/**
* Persists technicians registered inline on the Vendor & time step under the
* selected company, then creates the work order. Nothing is written before Create, so
* cancelling the wizard leaves no stray vendor records.
*/
export function useWizardCreateWithTechnicians({
vendors,
patchDraft,
createMutation,
}: UseWizardCreateWithTechniciansArgs) {
const addTechnicians = useAddTechniciansToCompany();
const mutate = (draft: WorkOrderWizardDraft) => {
const pending = pendingTechnicians(draft);
const company = (draft.vendorCompany ?? "").trim();
if (pending.length === 0 || company === "") {
createMutation.mutate(draft);
return;
}
// Mirror the vendor roster schema so an unparseable phone is caught here with a
// clear message instead of failing the roster PATCH and blocking Create with the
// raw backend sentence. A blank phone stays valid, matching the modal path.
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
toast.error("Enter a 10-digit phone number for each new technician.");
return;
}
const anchor = vendors.find((vendor) => vendor.companyName === company);
if (!anchor) {
toast.error("Select an existing vendor company before adding technicians.");
return;
}
void (async () => {
try {
const created = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: pending.map((technician) => ({
contactName: technician.name,
phone: technician.phone,
tradeSpecialties: anchor.tradeSpecialties,
})),
});
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
patchDraft(patch);
createMutation.mutate({ ...draft, ...patch });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to add technicians");
}
})();
};
return { mutate, isPending: createMutation.isPending || addTechnicians.isPending };
}

View file

@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
onPatch,
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
nextPm !== draft.pm
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}
: {};
const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p);

View file

@ -1,4 +1,5 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
@ -8,10 +9,7 @@ import {
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import {
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
getClosabilityGaps,
wizardDraftToClosabilityInput,
@ -30,7 +28,6 @@ import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
return (
<div className="space-y-5">
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
<WizardVendorCompanyFields
draft={draft}
vendors={vendors}
selectedVendor={selectedVendor}
onPatch={onPatch}
/>
{draft.vendorId !== "" && selectedVendor != null && (
<WizardVendorDetail

View file

@ -0,0 +1,141 @@
import { Building2, Wrench } from "lucide-react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
type WizardVendorCompanyFieldsProps = {
draft: WorkOrderWizardDraft;
vendors: VendorDropdownItem[];
selectedVendor: VendorDropdownItem | undefined;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
const ICON_STYLE = { color: "var(--muted-foreground)" };
function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()}
</span>
);
}
/** Company-first vendor selection with inline technician registration (prototype parity). */
export function WizardVendorCompanyFields({
draft,
vendors,
selectedVendor,
onPatch,
}: WizardVendorCompanyFieldsProps) {
const company = draft.vendorCompany || selectedVendor?.companyName || "";
const companyOptions = buildCompanyOptions(vendors, company);
const technicianOptions = vendors
.filter((vendor) => vendor.companyName === company)
.map((vendor) => ({
key: String(vendor.id),
label: vendor.contactName.trim() || vendor.companyName,
}));
return (
<>
<div>
<WizLabelRow
optional
onClear={
company === ""
? undefined
: () =>
onPatch({
vendorCompany: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
})
}
>
Company
</WizLabelRow>
<WizardFieldSelect
value={company}
placeholder="Select company…"
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
onPick={(nextCompany) =>
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
disabled={company === ""}
icon={
selectedVendor ? (
<TechnicianAvatar vendor={selectedVendor} />
) : (
<Wrench size={16} className="shrink-0" style={ICON_STYLE} />
)
}
searchPlaceholder="Search technician…"
options={technicianOptions}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorCompany: vendor?.companyName ?? company,
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{company !== "" && (
<WizardVendorNewTechnicians
technicians={draft.newTechnicians ?? []}
onChange={(newTechnicians) => onPatch({ newTechnicians })}
/>
)}
</>
);
}

View file

@ -0,0 +1,96 @@
import { X } from "lucide-react";
import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardVendorNewTechniciansProps = {
technicians: WizardNewTechnician[];
onChange: (technicians: WizardNewTechnician[]) => void;
};
/**
* Inline technician registration on the wizard's Vendor & time step.
* Rows stay local to the draft; they are persisted under the selected company only
* when the work order is created, so cancelling the wizard never creates a vendor.
*/
export function WizardVendorNewTechnicians({
technicians,
onChange,
}: WizardVendorNewTechniciansProps) {
const update = (index: number, patch: Partial<WizardNewTechnician>) =>
onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<>
{technicians.map((technician, index) => {
const label = `New Technician ${index + 1}`;
return (
<div
key={index}
role="group"
aria-label={label}
style={{
padding: 10,
borderRadius: "var(--radius-md)",
background: "var(--color-bg-muted)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: "var(--color-text-muted)" }}
>
{label}
</span>
<button
type="button"
title="Remove technician"
aria-label={`Remove ${label}`}
onClick={() => onChange(technicians.filter((_, i) => i !== index))}
style={{ color: "var(--color-text-muted)" }}
>
<X size={13} aria-hidden />
</button>
</div>
<div className="flex flex-col gap-2">
<div>
<WizSubLabel>Technician name</WizSubLabel>
<input
aria-label={`${label} name`}
value={technician.name}
onChange={(e) => update(index, { name: e.target.value })}
placeholder="Technician name"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizSubLabel>Technician phone</WizSubLabel>
<input
type="tel"
aria-label={`${label} phone`}
value={technician.phone}
onChange={(e) => update(index, { phone: e.target.value })}
placeholder="(000) 000-0000"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={() => onChange([...technicians, { name: "", phone: "" }])}
className="text-left text-[12px] font-medium"
style={{ color: "var(--color-primary)" }}
>
+ Add technician
</button>
</>
);
}

View file

@ -0,0 +1,96 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
export interface NewCompanyTechnician {
contactName: string;
phone: string;
tradeSpecialties?: string;
}
export interface AddTechniciansToCompanyInput {
/** Any persisted vendor (technician) of the target company; resolves the company roster. */
anchorVendorId: string | number;
technicians: NewCompanyTechnician[];
}
function takeAddedTechnician(
added: VendorRosterTechnician[],
contactName: string,
): VendorRosterTechnician {
const index = added.findIndex((technician) => technician.contactName.trim() === contactName);
if (index < 0) {
throw new Error("The technician was saved but could not be matched. Reload and try again.");
}
const [match] = added.splice(index, 1);
return match as VendorRosterTechnician;
}
/**
* Adds technicians to an existing vendor company through the additive roster PATCH.
*
* Work-order entry points used to POST a whole new vendor with blank company
* fields, which the create validator rejects (company phone/email) and which would
* overwrite company data. The additive path only appends technicians, keeps the
* company record untouched, and returns the persisted technician ids.
*/
export async function addTechniciansToCompany({
anchorVendorId,
technicians,
}: AddTechniciansToCompanyInput): Promise<VendorDropdownItem[]> {
const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId });
if (roster.companyId == null || roster.rowVersion === "") {
throw new Error("Vendor company could not be loaded. Try again.");
}
const existingIds = new Set(roster.technicians.map((technician) => String(technician.id)));
const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, {
rowVersion: roster.rowVersion,
addTechnicians: technicians.map((technician) => {
const phone = technician.phone.trim();
return {
contactName: technician.contactName.trim(),
phone,
email: "",
tradeSpecialties: technician.tradeSpecialties ?? "",
isActive: true,
...(phone === "" ? {} : { preferredContact: "Phone" }),
};
}),
});
const added = saved.technicians.filter(
(technician) => technician.id != null && !existingIds.has(String(technician.id)),
);
return technicians.map((technician) => {
const match = takeAddedTechnician(added, technician.contactName.trim());
const email = match.email.trim();
return {
id: match.id ?? "",
companyName: saved.name || roster.name,
contactName: match.contactName,
tradeSpecialties: match.tradeSpecialties,
address: saved.address,
distanceMiles: null,
email: email || undefined,
};
});
}
export function useAddTechniciansToCompany(): UseMutationResult<
VendorDropdownItem[],
Error,
AddTechniciansToCompanyInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addTechniciansToCompany,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
},
});
}

View file

@ -1,21 +0,0 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");
},
});
}

View file

@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export type WizardSchedMode = "date" | "week" | "range";
export interface WizardNewTechnician {
name: string;
phone: string;
}
export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
@ -47,6 +52,10 @@ export interface WorkOrderWizardDraft {
avetaRequired: boolean;
vendorId: string;
vendorName: string;
/** Company picked on the Vendor & time step; scopes the technician list. */
vendorCompany?: string;
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
newTechnicians?: WizardNewTechnician[];
techPhone: string;
vendorNotes: string;
apptStart: string;
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
avetaRequired: false,
vendorId: "",
vendorName: "",
vendorCompany: "",
newTechnicians: [],
techPhone: "",
vendorNotes: "",
apptStart: "",

View file

@ -8,8 +8,8 @@ import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_component
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),

View file

@ -4,8 +4,8 @@ import { VendorDialog } from "@/app/(protected)/workorders/_components/list/tabl
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),

View file

@ -1,14 +1,13 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { Vendor } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
mutateAsync,
isPending: false,
}),
@ -24,30 +23,19 @@ const existingVendor: VendorDropdownItem = {
email: "dispatch@gateway.test",
};
const createdVendor: Vendor = {
const createdVendor: VendorDropdownItem = {
id: 99,
companyId: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
email: "dispatch@gateway.test",
phone: "(314) 555-9999",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
city: "",
state: "",
zip: "",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
address: "1 Main St",
distanceMiles: null,
};
describe("VendorDialog stable technician identity", () => {
beforeEach(() => {
mutateAsync.mockReset();
mutateAsync.mockResolvedValue(createdVendor);
mutateAsync.mockResolvedValue([createdVendor]);
});
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
@ -90,13 +78,15 @@ describe("VendorDialog stable technician identity", () => {
expect(mutateAsync).toHaveBeenCalledTimes(1);
});
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Jordan Lee",
companyName: "Gateway Plumbing",
phone: "(314) 555-9999",
}),
);
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 10,
technicians: [
expect.objectContaining({
contactName: "Jordan Lee",
phone: "(314) 555-9999",
}),
],
});
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@ -115,12 +105,7 @@ describe("VendorDialog stable technician identity", () => {
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
renderWithProviders(
<VendorDialog
@ -161,8 +146,7 @@ describe("VendorDialog stable technician identity", () => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Casey New",
phone: "3145550188",
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
}),
);
@ -178,12 +162,7 @@ describe("VendorDialog stable technician identity", () => {
it("keeps a staged draft when the same company is picked again", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
renderWithProviders(
<VendorDialog
@ -215,8 +194,7 @@ describe("VendorDialog stable technician identity", () => {
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Casey New",
phone: "3145550188",
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
}),
);
});

View file

@ -44,7 +44,9 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}),
);
});
@ -87,7 +89,78 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("clears the vendor company and staged inline technicians when the service changes", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
pm: "HVAC",
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
};
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[
{ id: "1", value: "HVAC" },
{ id: "2", value: "Plumbing" },
]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
fireEvent.click(screen.getByRole("button", { name: "Plumbing" }));
expect(onPatch).toHaveBeenCalledWith({
pm: "Plumbing",
serviceId: "2",
extraServices: [],
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("keeps vendor company and staged technicians when the service is unchanged", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
pm: "HVAC",
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
};
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[{ id: "1", value: "HVAC" }]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", serviceId: "1", extraServices: [] });
});
});

View file

@ -0,0 +1,260 @@
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
const vendors: VendorDropdownItem[] = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
distanceMiles: null,
},
{
id: 52,
companyName: "Gateway Plumbing",
contactName: "Casey Pipe",
tradeSpecialties: "Plumbing",
address: "",
distanceMiles: null,
},
];
function draftWith(patch: Partial<WorkOrderWizardDraft>): WorkOrderWizardDraft {
return { ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete", ...patch };
}
describe("WizardStepVendorTime inline technician (SH-364)", () => {
it("hides the add action until a company is picked", () => {
render(
<WizardStepVendorTime
draft={draftWith({})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "+ Add technician" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled();
});
it("stages a new technician row for the picked company", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({ vendorCompany: "Vinewood LLC" })}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "+ Add technician" }));
expect(onPatch).toHaveBeenCalledWith({ newTechnicians: [{ name: "", phone: "" }] });
});
it("scopes technicians to the picked company", () => {
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /select technician/i }));
expect(screen.getByRole("button", { name: "Adam Whyte" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Casey Pipe" })).not.toBeInTheDocument();
});
it("clears the picked vendor and staged technicians when the company changes", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0100",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" }));
expect(onPatch).toHaveBeenCalledWith({
vendorCompany: "Gateway Plumbing",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("preserves the picked vendor and staged technicians when the same company is re-picked", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0100",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
const companyOptions = screen.getAllByRole("button", { name: "Vinewood LLC" });
fireEvent.click(companyOptions[companyOptions.length - 1]);
expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Vinewood LLC" });
});
});
describe("useWizardCreateWithTechnicians (SH-364)", () => {
beforeEach(() => {
mutateAsync.mockReset();
});
it("creates the work order directly when no technician was staged", () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
const draft = draftWith({ vendorCompany: "Vinewood LLC", newTechnicians: [] });
result.current.mutate(draft);
expect(mutateAsync).not.toHaveBeenCalled();
expect(createMutation.mutate).toHaveBeenCalledWith(draft);
});
it("persists staged technicians first and assigns a single new technician", async () => {
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
const createMutation = { mutate: vi.fn(), isPending: false };
const patchDraft = vi.fn();
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft, createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [
{ name: "Jordan Lee", phone: "(314) 555-0199" },
{ name: " ", phone: "" },
],
}),
);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 41,
technicians: [
{ contactName: "Jordan Lee", phone: "(314) 555-0199", tradeSpecialties: "HVAC" },
],
});
const expectedPatch = {
newTechnicians: [],
vendorId: "99",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0199",
};
expect(patchDraft).toHaveBeenCalledWith(expectedPatch);
expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch));
});
it("blocks Create and skips the roster save when a staged phone is not a valid number", async () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-01" }],
}),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
expect(createMutation.mutate).not.toHaveBeenCalled();
});
it("allows a staged technician with a blank phone, matching the modal path", async () => {
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 41,
technicians: [{ contactName: "Jordan Lee", phone: "", tradeSpecialties: "HVAC" }],
});
expect(createMutation.mutate).toHaveBeenCalled();
});
it("does not create the work order when the technician save fails", async () => {
mutateAsync.mockRejectedValue(new Error("boom"));
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
);
});
expect(createMutation.mutate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiPatch = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiPatch: (...args: unknown[]) => apiPatch(...args),
}));
import { addTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
const roster = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "office@vinewood.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
describe("addTechniciansToCompany (SH-365)", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPut.mockReset();
apiPatch.mockReset();
apiGet.mockResolvedValue(roster);
apiPatch.mockResolvedValue({
...roster,
rowVersion: "AAAAAAAAB9F=",
technicians: [
...roster.technicians,
{ id: 42, contactName: "Jordan Lee", phone: "(314) 555-0199", email: "", isActive: true },
],
});
});
it("appends the technician through the additive roster PATCH without company fields", async () => {
const created = await addTechniciansToCompany({
anchorVendorId: 41,
technicians: [{ contactName: " Jordan Lee ", phone: "3145550199", tradeSpecialties: "HVAC" }],
});
expect(apiGet).toHaveBeenCalledWith("vendor-company-roster", {
searchParams: { vendorId: "41" },
});
expect(apiPost).not.toHaveBeenCalled();
expect(apiPut).not.toHaveBeenCalled();
expect(apiPatch).toHaveBeenCalledTimes(1);
const [path, body] = apiPatch.mock.calls[0] ?? [];
expect(path).toBe("vendor-company-roster/7");
// No companyFields: an existing company's phone/email/address are never sent or blanked.
expect(body).toEqual({
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
});
expect(created).toEqual([
expect.objectContaining({ id: 42, contactName: "Jordan Lee", companyName: "Vinewood LLC" }),
]);
});
it("fails instead of returning a technician the server did not persist", async () => {
apiPatch.mockResolvedValue(roster);
await expect(
addTechniciansToCompany({
anchorVendorId: 41,
technicians: [{ contactName: "Jordan Lee", phone: "" }],
}),
).rejects.toThrow(/could not be matched/i);
});
});