From a30ac73d2982ea4cd670b05f9e933f4b9315ae5f Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 11 Aug 2026 14:01:31 -0300 Subject: [PATCH] feat(work-orders): draft technician until vendor dialog save (SH-193) (#89) * docs(work-orders): add pending BE contract checklist for design parity gaps * feat(work-orders): add technician affordance in wo vendor dialog * fix(work-orders): defer technician create until vendor dialog save * docs(work-orders): align pr description with jira and qa residuals * fix(vendors): harden draft technician optional field typing * refactor(workorders): extract vendor dialog body footer and save hook * fix(work-orders): coerce null company vendor for add-technician prop * fix(work-orders): preserve draft technician staging on save [SH-193] * fix(work-orders): select technicians by stable id [SH-193] Keep selectedKey as explicit dialog state (SiteDialog pattern) so same-name drafts do not collide with existing vendors, and cover the regression with interaction tests. Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: Alexandre Brandizzi --- .../table/cells/resolve-technician-key.ts | 36 ++++ .../cells/resolve-vendor-company-anchor.ts | 53 +++++ .../table/cells/use-vendor-dialog-save.ts | 75 +++++++ .../list/table/cells/vendor-cell.tsx | 12 +- .../cells/vendor-dialog-add-technician.tsx | 134 ++++++++++++ .../list/table/cells/vendor-dialog-body.tsx | 121 +++++++++++ .../list/table/cells/vendor-dialog-footer.tsx | 29 +++ .../list/table/cells/vendor-dialog-types.ts | 6 + .../list/table/cells/vendor-dialog.tsx | 203 +++++++++--------- .../list/table/wo-table-inline-row-cells.tsx | 1 + .../list/table/wo-table-row-service-cells.tsx | 1 + .../utils/build-technician-create-payload.ts | 36 ++++ src/domain/vendors/utils/draft-vendor.ts | 21 ++ .../vendors/utils/vendor-to-dropdown-item.ts | 15 ++ .../workorders/resolve-technician-key.test.ts | 44 ++++ .../vendor-dialog-add-technician.test.tsx | 51 +++++ .../vendor-dialog-stable-identity.test.tsx | 178 +++++++++++++++ .../build-technician-create-payload.test.ts | 106 +++++++++ tmp/pr-descriptions/pr7-add-technician.md | 27 +++ 19 files changed, 1043 insertions(+), 106 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts 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/use-vendor-dialog-save.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types.ts create mode 100644 src/domain/vendors/utils/build-technician-create-payload.ts create mode 100644 src/domain/vendors/utils/draft-vendor.ts create mode 100644 src/domain/vendors/utils/vendor-to-dropdown-item.ts create mode 100644 src/test/app/(protected)/workorders/resolve-technician-key.test.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-add-technician.test.tsx create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-stable-identity.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-technician-key.ts b/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts new file mode 100644 index 00000000..95a75a3b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key.ts @@ -0,0 +1,36 @@ +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; + +/** + * Board rows often have tech/company but no vendorId — resolve via vendors list. + * Prefers a stable vendor id over name+company matching. + */ +export function resolveTechnicianKey( + vendorId: string | number, + tech: string, + company: string, + vendors: VendorDropdownItem[], +): string { + const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : ""; + if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) { + return idStr; + } + if (idStr && vendors.length === 0) { + return idStr; + } + + const name = tech.trim(); + if (!name) { + return idStr; + } + + const byNameAndCompany = + vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ?? + vendors.find((vendor) => vendor.contactName === name); + + if (byNameAndCompany) { + return String(byNameAndCompany.id); + } + + // Orphan tech string used as option key when no vendor row matches (legacy board cells). + return idStr || name; +} 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/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts new file mode 100644 index 00000000..502714c6 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -0,0 +1,75 @@ +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"; + +type UseVendorDialogSaveArgs = { + allVendors: VendorDropdownItem[]; + pendingCreates: Record; + selectedKey: string; + selectedVendor: VendorDropdownItem | undefined; + resolvedCompany: string; + anchorCompanyName: string; + tech: string; + phone: string; + createVendor: ReturnType; + onSave: (patch: VendorPatch) => void; + onOpenChange: (open: boolean) => void; + setSaveError: (error: string | null) => void; +}; + +export function useVendorDialogSave({ + allVendors, + pendingCreates, + selectedKey, + selectedVendor, + resolvedCompany, + anchorCompanyName, + tech, + phone, + createVendor, + onSave, + onOpenChange, + setSaveError, +}: UseVendorDialogSaveArgs) { + const resolveSaveVendor = () => + allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? + allVendors.find( + (vendor) => + vendor.contactName === tech && + vendor.companyName === (resolvedCompany || anchorCompanyName), + ) ?? + selectedVendor; + + const handleSave = async () => { + setSaveError(null); + let saveVendor = resolveSaveVendor(); + const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; + + if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) { + try { + const created = await createVendor.mutateAsync({ + ...pendingPayload, + phone: phone.trim() || pendingPayload.phone, + }); + saveVendor = vendorToDropdownItem(created); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create technician"; + setSaveError(message); + return; + } + } + + onSave({ + vendorId: saveVendor ? String(saveVendor.id) : "", + company: resolvedCompany || anchorCompanyName, + tech: saveVendor?.contactName ?? tech, + techPhone: phone, + }); + onOpenChange(false); + }; + + return { handleSave }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx index a627d8da..8cd6dca2 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-cell.tsx @@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron"; import { VendorDialog, type VendorPatch } from "./vendor-dialog"; type VendorCellProps = { + vendorId?: string | number; company: string; tech: string; techPhone?: string; @@ -13,7 +14,15 @@ type VendorCellProps = { onSave: (patch: VendorPatch) => void; }; -export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) { +export function VendorCell({ + vendorId = "", + company, + tech, + techPhone, + vendors, + q, + onSave, +}: VendorCellProps) { const [open, setOpen] = useState(false); const hasAny = !!(tech || company); @@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven void; +}; + +export function VendorDialogAddTechnician({ + companyName, + companyVendor, + onCreated, +}: VendorDialogAddTechnicianProps) { + 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 handleAdd = () => { + 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); + const draftItem = createDraftVendorDropdownItem(parsed.data); + onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data); + resetForm(); + setExpanded(false); + }; + + 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-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx new file mode 100644 index 00000000..5b58de5e --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -0,0 +1,121 @@ +import { Wrench } from "lucide-react"; +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { Text } from "@/components/ui/text"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; +import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician"; +import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; + +type VendorDialogBodyProps = { + selectedKey: string; + technicianOptions: TechnicianOption[]; + anchorCompanyName: string; + companyVendorForAdd: VendorDropdownItem | undefined; + tech: string; + phone: string; + notes: string; + resolvedCompany: string; + selectedVendor: VendorDropdownItem | undefined; + saveError: string | null; + onPick: (key: string) => void; + onPhoneChange: (phone: string) => void; + onNotesChange: (notes: string) => void; + onTechnicianCreated: ( + created: VendorDropdownItem, + createdPhone: string, + payload: VendorFormValues, + ) => void; +}; + +export function VendorDialogBody({ + selectedKey, + technicianOptions, + anchorCompanyName, + companyVendorForAdd, + tech, + phone, + notes, + resolvedCompany, + selectedVendor, + saveError, + onPick, + onPhoneChange, + onNotesChange, + onTechnicianCreated, +}: VendorDialogBodyProps) { + return ( + <> +
+
+ Technician + + } + searchPlaceholder="Search technician…" + options={technicianOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onPick} + /> + {companyVendorForAdd != null && ( +
+ +
+ )} +
+ + {Boolean(tech) && ( +
+ Technician Phone + onPhoneChange(e.target.value)} + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+ )} + + {Boolean(resolvedCompany) && ( +
+ Company + +
+ )} + +
+ Notes +