From ed9c380efb3dd394407bebc78aed47d801a8ba15 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Fri, 31 Jul 2026 16:55:45 -0300 Subject: [PATCH] feat(work-orders): add technician affordance in wo vendor dialog --- .../cells/resolve-vendor-company-anchor.ts | 53 +++++++ .../cells/vendor-dialog-add-technician.tsx | 144 ++++++++++++++++++ .../list/table/cells/vendor-dialog.tsx | 57 ++++++- .../utils/build-technician-create-payload.ts | 36 +++++ .../vendors/utils/vendor-to-dropdown-item.ts | 15 ++ .../vendor-dialog-add-technician.test.tsx | 88 +++++++++++ .../build-technician-create-payload.test.ts | 106 +++++++++++++ tmp/pr-descriptions/pr7-add-technician.md | 21 +++ 8 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician.tsx create mode 100644 src/domain/vendors/utils/build-technician-create-payload.ts create mode 100644 src/domain/vendors/utils/vendor-to-dropdown-item.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-add-technician.test.tsx create mode 100644 src/test/domain/vendors/utils/build-technician-create-payload.test.ts create mode 100644 tmp/pr-descriptions/pr7-add-technician.md diff --git a/src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts b/src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts new file mode 100644 index 00000000..3357830c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor.ts @@ -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(); + 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()); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician.tsx new file mode 100644 index 00000000..7327e816 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician.tsx @@ -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(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 ( + + ); + } + + return ( +
+ + New technician for {companyName} + +
+ Technician Name + setContactName(e.target.value)} + placeholder="Full name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+
+ Technician Phone + setPhone(e.target.value)} + placeholder="(000) 000-0000" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+ {Boolean(error) && ( + + {error} + + )} +
+ + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx index 5862c7f0..665a16e6 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog.tsx @@ -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([]); + + 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 ( + {companyVendor != null && ( +
+ +
+ )} {Boolean(t) && ( @@ -131,7 +166,7 @@ export function VendorDialog({ )} - {Boolean(t) && ( + {Boolean(resolvedCompany) && (
Company @@ -162,9 +197,17 @@ export function VendorDialog({