feat(work-orders): add technician affordance in wo vendor dialog

This commit is contained in:
arthur.bassi 2026-07-31 16:55:45 -03:00
parent a0214cc01f
commit ed9c380efb
8 changed files with 513 additions and 7 deletions

View file

@ -0,0 +1,53 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
type ResolveVendorCompanyAnchorInput = {
company: string;
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
vendors: VendorDropdownItem[];
};
export function resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors,
}: ResolveVendorCompanyAnchorInput): {
companyName: string;
companyVendor: VendorDropdownItem | null;
} {
const companyName = resolvedCompany || company;
if (!companyName) {
return { companyName: "", companyVendor: null };
}
const companyVendor =
selectedVendor ??
vendors.find((vendor) => vendor.companyName === companyName) ??
({
id: "",
companyName,
contactName: "",
tradeSpecialties: "",
address: "",
distanceMiles: null,
} satisfies VendorDropdownItem);
return { companyName, companyVendor };
}
export function mergeVendorDropdownItems(
vendors: VendorDropdownItem[],
addedVendors: VendorDropdownItem[],
): VendorDropdownItem[] {
const byId = new Map<string, VendorDropdownItem>();
for (const vendor of vendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
for (const vendor of addedVendors) {
if (vendor.id === "") continue;
byId.set(String(vendor.id), vendor);
}
return Array.from(byId.values());
}

View file

@ -0,0 +1,144 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
type VendorDialogAddTechnicianProps = {
companyName: string;
companyVendor: VendorDropdownItem;
onCreated: (vendor: VendorDropdownItem, phone: string) => void;
};
export function VendorDialogAddTechnician({
companyName,
companyVendor,
onCreated,
}: VendorDialogAddTechnicianProps) {
const createVendor = useCreateVendor();
const [expanded, setExpanded] = useState(false);
const [contactName, setContactName] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setContactName("");
setPhone("");
setError(null);
};
const handleCreate = () => {
const payload = buildTechnicianCreatePayload({
companyVendor,
contactName,
phone,
});
const parsed = vendorSchema.safeParse(payload);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
const message =
fieldErrors.contactName?.[0] ??
fieldErrors.phone?.[0] ??
fieldErrors.companyName?.[0] ??
"Enter a valid technician name";
setError(message);
return;
}
setError(null);
createVendor.mutate(parsed.data, {
onSuccess: (created) => {
const dropdownItem = vendorToDropdownItem(created);
onCreated(dropdownItem, created.phone || phone.trim());
resetForm();
setExpanded(false);
},
onError: (mutationError: Error) => {
setError(mutationError.message || "Failed to create technician");
},
});
};
if (!expanded) {
return (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ color: "var(--color-primary)" }}
>
<Plus size={12} className="shrink-0" aria-hidden />+ Add technician
</button>
);
}
return (
<div
className="space-y-2 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<Text as="p" variant="caption" sx={{ fontSize: 12, color: "var(--muted-foreground)" }}>
New technician for {companyName}
</Text>
<div>
<FieldLabel>Technician Name</FieldLabel>
<input
autoFocus
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Full name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(000) 000-0000"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(error) && (
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
{error}
</Text>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
disabled={createVendor.isPending}
onClick={() => {
resetForm();
setExpanded(false);
}}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={createVendor.isPending || contactName.trim() === ""}
onClick={handleCreate}
>
{createVendor.isPending ? "Creating…" : "Create"}
</Button>
</div>
</div>
);
}

View file

@ -16,6 +16,11 @@ import {
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
export type VendorPatch = {
vendorId: string;
@ -46,10 +51,16 @@ export function VendorDialog({
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company),
[vendors, tech, company],
() => buildTechnicianOptions(allVendors, tech, company),
[allVendors, tech, company],
);
useEffect(() => {
@ -57,6 +68,7 @@ export function VendorDialog({
setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions));
setNotes("");
setAddedVendors([]);
}
}, [open, tech, techPhone, technicianOptions]);
@ -64,8 +76,16 @@ export function VendorDialog({
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
technicianOptions.find((o) => o.name === t || o.key === t);
const selectedKey = selected?.key ?? "";
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
const resolvedCompany = t ? (selected?.company ?? company) : company;
const selectedVendor =
selected?.vendor ?? allVendors.find((v) => v.companyName === resolvedCompany);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
@ -74,6 +94,12 @@ export function VendorDialog({
setPhone(phoneForOption(name, technicianOptions));
};
const handleTechnicianCreated = (created: VendorDropdownItem, createdPhone: string) => {
setAddedVendors((current) => [...current, created]);
setT(created.contactName);
setPhone(createdPhone);
};
return (
<Dialog
open={open}
@ -116,6 +142,15 @@ export function VendorDialog({
}))}
onPick={handlePick}
/>
{companyVendor != null && (
<div className="mt-2">
<VendorDialogAddTechnician
companyName={anchorCompanyName}
companyVendor={companyVendor}
onCreated={handleTechnicianCreated}
/>
</div>
)}
</div>
{Boolean(t) && (
@ -131,7 +166,7 @@ export function VendorDialog({
</div>
)}
{Boolean(t) && (
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
@ -162,9 +197,17 @@ export function VendorDialog({
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
const saveVendor =
allVendors.find(
(vendor) =>
vendor.contactName === t &&
vendor.companyName === (resolvedCompany || anchorCompanyName),
) ??
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
selectedVendor;
onSave({
vendorId: selectedVendor ? String(selectedVendor.id) : "",
company: resolvedCompany,
vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName,
tech: t,
techPhone: phone,
});

View file

@ -0,0 +1,36 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
type BuildTechnicianCreatePayloadInput = {
companyVendor: Pick<VendorDropdownItem, "companyName" | "email" | "address" | "tradeSpecialties">;
contactName: string;
phone?: string;
companyId?: string | number | null;
};
/** Builds a vendor create payload for a new technician under an existing company. */
export function buildTechnicianCreatePayload({
companyVendor,
contactName,
phone = "",
companyId = null,
}: BuildTechnicianCreatePayloadInput): VendorFormValues {
return {
companyName: companyVendor.companyName,
contactName: contactName.trim(),
email: companyVendor.email ?? "",
phone: phone.trim(),
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: companyVendor.address ?? "",
city: "",
state: "",
zip: "",
tradeSpecialties: companyVendor.tradeSpecialties ?? "",
isActive: true,
companyId,
preferredContact: "Phone",
};
}

View file

@ -0,0 +1,15 @@
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { Vendor } from "@/domain/vendors/types/vendor";
export function vendorToDropdownItem(vendor: Vendor): VendorDropdownItem {
const email = vendor.email.trim();
return {
id: vendor.id ?? "",
companyName: vendor.companyName,
contactName: vendor.contactName,
tradeSpecialties: vendor.tradeSpecialties,
address: vendor.address,
distanceMiles: null,
email: email || undefined,
};
}

View file

@ -0,0 +1,88 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
const mutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
mutate,
isPending: false,
}),
}));
const companyVendor: VendorDropdownItem = {
id: 10,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
};
describe("VendorDialogAddTechnician", () => {
it("creates a technician under the company and reports selection on success", async () => {
const onCreated = vi.fn();
mutate.mockImplementation((_payload, options) => {
options?.onSuccess?.({
id: 55,
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
email: "dispatch@gateway.test",
phone: "(314) 555-0199",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
});
});
renderWithProviders(
<VendorDialogAddTechnician
companyName="Gateway Plumbing"
companyVendor={companyVendor}
onCreated={onCreated}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
fireEvent.change(screen.getByPlaceholderText("Full name"), {
target: { value: "Jordan Lee" },
});
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
target: { value: "3145550199" },
});
fireEvent.click(screen.getByRole("button", { name: /^create$/i }));
await waitFor(() => {
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
}),
expect.any(Object),
);
});
expect(onCreated).toHaveBeenCalledWith(
expect.objectContaining({
id: 55,
contactName: "Jordan Lee",
companyName: "Gateway Plumbing",
}),
"(314) 555-0199",
);
});
});

View file

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { Vendor } from "@/domain/vendors/types/vendor";
describe("buildTechnicianCreatePayload", () => {
it("copies company fields and sets the new technician identity", () => {
const payload = buildTechnicianCreatePayload({
companyVendor: {
companyName: "Gateway Plumbing",
email: "dispatch@gateway.test",
address: "1 Main St",
tradeSpecialties: "Plumbing",
},
contactName: "Jordan Lee",
phone: "(314) 555-0100",
companyId: 42,
});
expect(payload).toMatchObject({
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
phone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "1 Main St",
tradeSpecialties: "Plumbing",
companyId: 42,
isActive: true,
});
});
});
describe("resolveVendorCompanyAnchor", () => {
const vendors: VendorDropdownItem[] = [
{
id: 10,
companyName: "Gateway Plumbing",
contactName: "Adam Whyte",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: null,
},
];
it("returns null anchor when no company context exists", () => {
expect(
resolveVendorCompanyAnchor({
company: "",
resolvedCompany: "",
selectedVendor: undefined,
vendors,
}),
).toEqual({ companyName: "", companyVendor: null });
});
it("merges newly created vendors without dropping existing options", () => {
const added: VendorDropdownItem = {
id: 55,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: null,
};
expect(mergeVendorDropdownItems(vendors, [added])).toHaveLength(2);
});
});
describe("vendorToDropdownItem", () => {
it("maps a created vendor into the work-order dropdown shape", () => {
const vendor: Vendor = {
id: 99,
companyId: "co-1",
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
email: "jordan@gateway.test",
phone: "(314) 555-0100",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
};
expect(vendorToDropdownItem(vendor)).toEqual({
id: 99,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: null,
email: "jordan@gateway.test",
});
});
});

View file

@ -0,0 +1,21 @@
## Summary
- Adds **+ Add technician** to the WO board vendor dialog when a company context is known (from the row or the current selection).
- Creates the technician via the existing **`POST vendors`** REST endpoint (`vendorsApi.create` / `useCreateVendor`), reusing the selected company's profile fields and registering a new `contactName` under that company.
- After a successful create, the new technician is merged into the dialog options and auto-selected for the single-tech WO assignment.
## Backend
Uses the established vendor create API (`API_PATHS.rest.vendors` → `POST /vendors`). Each vendor record represents one technician; linking to an existing company is done by sending the same `companyName` (and optional `companyId` when available from facets elsewhere). **No new endpoint** — confirm with BE that duplicate `companyName` + distinct `contactName` rows are the intended model for multiple technicians per company.
## Test plan
- [ ] Open a WO with an assigned company on the board → vendor cell → **+ Add technician** is visible.
- [ ] Create a technician with name (+ optional phone) → new tech appears selected in the dialog.
- [ ] Save → WO row shows the new technician and vendor id.
- [ ] Open dialog on a row with no company → add affordance hidden until a company/tech context exists.
- [ ] `npm run verify`
## Out of scope
- Multi-POC, multi-tech arrays on WO, vendors directory redesign.