Merge pull request #171 from Sea-Haven-Industries/fix/SH-181-vendor-save-gate
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

fix(workorders): correct vendor save behavior
This commit is contained in:
Arthur Bassi 2026-09-09 17:46:19 -03:00 • committed by GitHub
commit a7d359ffc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 705 additions and 143 deletions

View file

@ -0,0 +1,177 @@
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 {
buildCompanyOptions,
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
type UseVendorDialogFormArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
vendorId: string | number;
company: string;
tech: string;
techPhone: string;
vendors: VendorDropdownItem[];
onSave: (patch: VendorPatch) => void;
};
export function useVendorDialogForm({
open,
onOpenChange,
vendorId,
company,
tech,
techPhone,
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const createVendor = useCreateVendor();
const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const companyOptions = useMemo(
() => buildCompanyOptions(allVendors, company),
[allVendors, company],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(allVendors, selectedCompany, tech, company),
[allVendors, selectedCompany, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
const initialOptions = buildTechnicianOptions(vendors, company, tech, company);
setSelectedCompany(company);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, initialOptions));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
// invariant: rebuilding options after Add must not clear staged technician drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedCompany;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company: selectedCompany,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const canSubmit = computeVendorDialogCanSubmit({
current: {
company: selectedCompany,
selectedKey,
phone,
notes,
pendingCreateCount: Object.keys(pendingCreates).length,
},
baseline: {
company,
selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors),
phone:
techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)),
notes: "",
pendingCreateCount: 0,
},
});
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
canSubmit,
});
return {
companyOptions,
technicianOptions,
selected,
t,
phone,
notes,
resolvedCompany,
selectedVendor,
selectedCompany,
selectedKey,
anchorCompanyName,
companyVendor,
saveError,
canSubmit,
isPending: createVendor.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
setSelectedCompany(nextCompany);
if (selectedOption?.company !== nextCompany) {
setAddedVendors([]);
setPendingCreates({});
setSelectedKey("");
setT("");
setPhone("");
}
},
handlePick: (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
},
handleTechnicianCreated: (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
},
setPhone,
setNotes,
};
}

View file

@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = {
onSave: (patch: VendorPatch) => void; onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void; setSaveError: (error: string | null) => void;
canSubmit: boolean;
}; };
export function useVendorDialogSave({ export function useVendorDialogSave({
@ -33,6 +34,7 @@ export function useVendorDialogSave({
onSave, onSave,
onOpenChange, onOpenChange,
setSaveError, setSaveError,
canSubmit,
}: UseVendorDialogSaveArgs) { }: UseVendorDialogSaveArgs) {
const resolveSaveVendor = () => const resolveSaveVendor = () =>
allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
@ -44,6 +46,7 @@ export function useVendorDialogSave({
selectedVendor; selectedVendor;
const handleSave = async () => { const handleSave = async () => {
if (!canSubmit) return;
setSaveError(null); setSaveError(null);
let saveVendor = resolveSaveVendor(); let saveVendor = resolveSaveVendor();
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
@ -65,8 +68,8 @@ export function useVendorDialogSave({
onSave({ onSave({
vendorId: saveVendor ? String(saveVendor.id) : "", vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName, company: resolvedCompany || anchorCompanyName,
tech: saveVendor?.contactName ?? tech, tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
techPhone: phone, techPhone: selectedKey ? phone : "",
}); });
onOpenChange(false); onOpenChange(false);
}; };

View file

@ -1,4 +1,4 @@
import { Wrench } from "lucide-react"; import { Building2, Wrench } from "lucide-react";
import { import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
DLG_INPUT_STYLE, DLG_INPUT_STYLE,
@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician"; 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 { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; import type {
CompanyOption,
TechnicianOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
type VendorDialogBodyProps = { type VendorDialogBodyProps = {
selectedCompany: string;
companyOptions: CompanyOption[];
selectedKey: string; selectedKey: string;
technicianOptions: TechnicianOption[]; technicianOptions: TechnicianOption[];
anchorCompanyName: string; anchorCompanyName: string;
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
resolvedCompany: string; resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined; selectedVendor: VendorDropdownItem | undefined;
saveError: string | null; saveError: string | null;
onCompanyPick: (company: string) => void;
onPick: (key: string) => void; onPick: (key: string) => void;
onPhoneChange: (phone: string) => void; onPhoneChange: (phone: string) => void;
onNotesChange: (notes: string) => void; onNotesChange: (notes: string) => void;
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
}; };
export function VendorDialogBody({ export function VendorDialogBody({
selectedCompany,
companyOptions,
selectedKey, selectedKey,
technicianOptions, technicianOptions,
anchorCompanyName, anchorCompanyName,
@ -44,6 +52,7 @@ export function VendorDialogBody({
resolvedCompany, resolvedCompany,
selectedVendor, selectedVendor,
saveError, saveError,
onCompanyPick,
onPick, onPick,
onPhoneChange, onPhoneChange,
onNotesChange, onNotesChange,
@ -53,17 +62,46 @@ export function VendorDialogBody({
<> <>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<div> <div>
<FieldLabel>Technician</FieldLabel> <FieldLabel required>Company</FieldLabel>
<WizardFieldSelect
value={selectedCompany}
placeholder="Select company …"
icon={
<Building2
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={onCompanyPick}
/>
</div>
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company info</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Technician (optional)</FieldLabel>
<WizardFieldSelect <WizardFieldSelect
value={selectedKey} value={selectedKey}
placeholder="Search technician …" placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
disabled={!selectedCompany}
icon={ icon={
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} /> <Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
} }
searchPlaceholder="Search technician…" searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({ options={technicianOptions.map((option) => ({
key: option.key, key: option.key,
label: option.label, label: option.name,
}))} }))}
onPick={onPick} onPick={onPick}
/> />
@ -80,7 +118,7 @@ export function VendorDialogBody({
{Boolean(tech) && ( {Boolean(tech) && (
<div> <div>
<FieldLabel>Technician Phone</FieldLabel> <FieldLabel>Technician Phone (optional)</FieldLabel>
<input <input
type="tel" type="tel"
value={phone} value={phone}
@ -91,15 +129,8 @@ export function VendorDialogBody({
</div> </div>
)} )}
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div> <div>
<FieldLabel>Notes</FieldLabel> <FieldLabel>Notes (optional)</FieldLabel>
<textarea <textarea
value={notes} value={notes}
onChange={(e) => onNotesChange(e.target.value)} onChange={(e) => onNotesChange(e.target.value)}

View file

@ -0,0 +1,26 @@
export type VendorDialogSubmitSnapshot = {
company: string;
selectedKey: string;
phone: string;
notes: string;
pendingCreateCount: number;
};
type VendorDialogCanSubmitInput = {
current: VendorDialogSubmitSnapshot;
baseline: VendorDialogSubmitSnapshot;
};
export function computeVendorDialogCanSubmit({
current,
baseline,
}: VendorDialogCanSubmitInput): boolean {
const valid = current.company.trim() !== "";
const hasNewTechs = current.pendingCreateCount > 0;
const dirty =
current.company !== baseline.company ||
current.selectedKey !== baseline.selectedKey ||
current.phone !== baseline.phone ||
current.pendingCreateCount !== baseline.pendingCreateCount;
return valid && (dirty || hasNewTechs);
}

View file

@ -1,4 +1,4 @@
import { ExternalLink, Mail, User, Wrench } from "lucide-react"; import { ExternalLink, Mail, MapPin } from "lucide-react";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label"; import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
</span> </span>
)} )}
</div> </div>
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />} {Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
{Boolean(vendor?.tradeSpecialties) && (
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
)}
{Boolean(email) && <Detail icon={Mail} text={email} />} {Boolean(email) && <Detail icon={Mail} text={email} />}
{vendor != null && Boolean(vendor.address) && ( {vendor != null && Boolean(vendor.address) && (
<a <a

View file

@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
onCancel: () => void; onCancel: () => void;
onSave: () => void; onSave: () => void;
viewOnly?: boolean; viewOnly?: boolean;
canSubmit?: boolean;
};
const lookDisabledSx = {
opacity: 0.5,
cursor: "not-allowed",
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
}; };
export function VendorDialogFooter({ export function VendorDialogFooter({
@ -12,6 +19,7 @@ export function VendorDialogFooter({
onCancel, onCancel,
onSave, onSave,
viewOnly = false, viewOnly = false,
canSubmit = false,
}: VendorDialogFooterProps) { }: VendorDialogFooterProps) {
if (viewOnly) { if (viewOnly) {
return ( return (
@ -36,7 +44,13 @@ export function VendorDialogFooter({
<Button <Button
className="min-h-0 px-3 py-1 text-[12px]" className="min-h-0 px-3 py-1 text-[12px]"
disabled={isPending} disabled={isPending}
onClick={() => void onSave()} aria-disabled={!canSubmit && !isPending}
// why: AAP Save stays clickable; native disabled would change the no-op look-disabled gate.
sx={canSubmit || isPending ? undefined : lookDisabledSx}
onClick={() => {
if (!canSubmit) return;
void onSave();
}}
> >
{isPending ? "Saving…" : "Save"} {isPending ? "Saving…" : "Save"}
</Button> </Button>

View file

@ -10,10 +10,44 @@ export type TechnicianOption = {
vendor?: VendorDropdownItem; vendor?: VendorDropdownItem;
}; };
export type CompanyOption = {
key: string;
label: string;
vendor?: VendorDropdownItem;
};
export function buildCompanyOptions(
vendors: VendorDropdownItem[],
currentCompany: string,
): CompanyOption[] {
const companies = new Map<string, CompanyOption>();
for (const vendor of vendors) {
const companyName = vendor.companyName.trim();
if (companyName && !companies.has(companyName)) {
companies.set(companyName, {
key: companyName,
label: companyName,
vendor,
});
}
}
if (currentCompany && !companies.has(currentCompany)) {
companies.set(currentCompany, {
key: currentCompany,
label: currentCompany,
});
}
return Array.from(companies.values());
}
export function buildTechnicianOptions( export function buildTechnicianOptions(
vendors: VendorDropdownItem[], vendors: VendorDropdownItem[],
selectedCompany: string,
tech: string, tech: string,
company: string, currentCompany: string,
): TechnicianOption[] { ): TechnicianOption[] {
const options = new Map<string, TechnicianOption>(); const options = new Map<string, TechnicianOption>();
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
}; };
for (const vendor of vendors) { for (const vendor of vendors) {
if (vendor.companyName !== selectedCompany) {
continue;
}
const contact = vendor.contactName.trim() || vendor.companyName.trim(); const contact = vendor.contactName.trim() || vendor.companyName.trim();
add({ add({
key: String(vendor.id), key: String(vendor.id),
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
}); });
} }
if (tech) { if (tech && selectedCompany === currentCompany) {
const vendor = const vendor =
vendors.find((v) => v.contactName === tech && v.companyName === company) ?? vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
vendors.find((v) => v.contactName === tech) ?? vendors.find((v) => v.contactName === tech);
vendors.find((v) => v.companyName === company);
const key = vendor ? String(vendor.id) : tech; const key = vendor ? String(vendor.id) : tech;
add({ add({
key, key,
label: company && company !== tech ? `${tech} · ${company}` : tech, label: tech,
name: tech, name: tech,
company: company || tech, company: currentCompany,
phone: "", phone: "",
vendor, vendor,
}); });

View file

@ -1,22 +1,11 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react"; import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; 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 {
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body"; import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer"; import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import { import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
@ -44,85 +33,15 @@ export function VendorDialog({
viewOnly = false, viewOnly = false,
onSave, onSave,
}: VendorDialogProps) { }: VendorDialogProps) {
const createVendor = useCreateVendor(); const form = useVendorDialogForm({
const [selectedKey, setSelectedKey] = useState(() => open,
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(allVendors, tech, company),
[allVendors, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
};
const handleTechnicianCreated = (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
};
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
onOpenChange, onOpenChange,
setSaveError, vendorId,
company,
tech,
techPhone,
vendors,
onSave,
}); });
return ( return (
@ -134,43 +53,47 @@ export function VendorDialog({
...dialogBlurBackdropSlotProps, ...dialogBlurBackdropSlotProps,
paper: { paper: {
className: "w-full", className: "w-full",
style: { borderRadius: 12, maxWidth: 480 }, style: { borderRadius: 12, maxWidth: 560 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(), onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
}, },
}} }}
> >
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}> <DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}> <DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Technician Vendor
</DialogTitle> </DialogTitle>
<Text as="p" className="sr-only"> <Text as="p" className="sr-only">
Search and select the technician Search and select the vendor company
</Text> </Text>
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0"> <fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
<VendorDialogBody <VendorDialogBody
selectedKey={selectedKey} selectedCompany={form.selectedCompany}
technicianOptions={technicianOptions} companyOptions={form.companyOptions}
anchorCompanyName={anchorCompanyName} selectedKey={form.selectedKey}
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)} technicianOptions={form.technicianOptions}
tech={selected?.name ?? t} anchorCompanyName={form.anchorCompanyName}
phone={phone} companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
notes={notes} tech={form.selected?.name ?? form.t}
resolvedCompany={resolvedCompany} phone={form.phone}
selectedVendor={selectedVendor} notes={form.notes}
saveError={saveError} resolvedCompany={form.resolvedCompany}
onPick={handlePick} selectedVendor={form.selectedVendor}
onPhoneChange={setPhone} saveError={form.saveError}
onNotesChange={setNotes} onCompanyPick={form.handleCompanyPick}
onTechnicianCreated={handleTechnicianCreated} onPick={form.handlePick}
onPhoneChange={form.setPhone}
onNotesChange={form.setNotes}
onTechnicianCreated={form.handleTechnicianCreated}
/> />
</fieldset> </fieldset>
<VendorDialogFooter <VendorDialogFooter
viewOnly={viewOnly} viewOnly={viewOnly}
isPending={createVendor.isPending} isPending={form.isPending}
canSubmit={form.canSubmit}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
onSave={handleSave} onSave={form.handleSave}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
this.code = code; this.code = code;
} }
} }
/** why: backend field names must never be exposed through user-facing board toasts. */
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
if (error.code === "DispatchRequired") {
return "Select a vendor company before saving.";
}
return error.message;
}

View file

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { import {
boardPatchValidationMessage,
BoardPatchConflictError, BoardPatchConflictError,
BoardPatchValidationError, BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors"; } from "@/domain/work-orders/errors/board-patch-errors";
@ -161,7 +162,7 @@ export function useWorkOrderTableMutations(
toast.error(error.message || "Conflict — refreshed with latest data"); toast.error(error.message || "Conflict — refreshed with latest data");
} else if (error instanceof BoardPatchValidationError) { } else if (error instanceof BoardPatchValidationError) {
clearPatch(id); clearPatch(id);
toast.error(error.message); toast.error(boardPatchValidationMessage(error));
} else { } else {
clearPatch(id); clearPatch(id);
toast.error(error.message || "Failed to update work order"); toast.error(error.message || "Failed to update work order");

View file

@ -61,7 +61,12 @@ describe("DialogCancelButton VendorDialog tokens", () => {
it("matches VendorDialogFooter Cancel classes (ghost + 12px compact padding)", () => { it("matches VendorDialogFooter Cancel classes (ghost + 12px compact padding)", () => {
renderWithProviders( renderWithProviders(
<> <>
<VendorDialogFooter isPending={false} onCancel={() => undefined} onSave={() => undefined} /> <VendorDialogFooter
isPending={false}
canSubmit={false}
onCancel={() => undefined}
onSave={() => undefined}
/>
<DialogCancelButton /> <DialogCancelButton />
</>, </>,
); );

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
computeVendorDialogCanSubmit,
type VendorDialogSubmitSnapshot,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
const empty: VendorDialogSubmitSnapshot = {
company: "",
selectedKey: "",
phone: "",
notes: "",
pendingCreateCount: 0,
};
const assigned: VendorDialogSubmitSnapshot = {
company: "Gateway Plumbing",
selectedKey: "10",
phone: "(314) 555-0100",
notes: "",
pendingCreateCount: 0,
};
describe("computeVendorDialogCanSubmit", () => {
it("rejects an empty untouched assignment", () => {
expect(
computeVendorDialogCanSubmit({
current: empty,
baseline: empty,
}),
).toBe(false);
});
it("rejects an existing assignment with no field changes", () => {
expect(
computeVendorDialogCanSubmit({
current: assigned,
baseline: assigned,
}),
).toBe(false);
});
it("allows save after the technician key changes", () => {
expect(
computeVendorDialogCanSubmit({
current: { ...assigned, selectedKey: "11" },
baseline: assigned,
}),
).toBe(true);
});
it("allows save when a new technician is staged", () => {
expect(
computeVendorDialogCanSubmit({
current: { ...assigned, selectedKey: "draft-1", pendingCreateCount: 1 },
baseline: assigned,
}),
).toBe(true);
});
it("rejects notes-only changes because notes are not persisted", () => {
expect(
computeVendorDialogCanSubmit({
current: { ...assigned, notes: "Call before arrival" },
baseline: assigned,
}),
).toBe(false);
});
it("does not allow notes-only save without a company or technician", () => {
expect(
computeVendorDialogCanSubmit({
current: { ...empty, notes: "Call before arrival" },
baseline: empty,
}),
).toBe(false);
});
it("allows a company-only assignment", () => {
expect(
computeVendorDialogCanSubmit({
current: { ...empty, company: "Gateway Plumbing" },
baseline: empty,
}),
).toBe(true);
});
it("rejects a technician without a company", () => {
expect(
computeVendorDialogCanSubmit({
current: { ...empty, selectedKey: "10" },
baseline: empty,
}),
).toBe(false);
});
});

View file

@ -0,0 +1,159 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
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: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
const jordan: VendorDropdownItem = {
id: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
address: "1 Main St",
distanceMiles: 4.2,
email: "dispatch@gateway.test",
};
const casey: VendorDropdownItem = {
...jordan,
id: 11,
contactName: "Casey New",
};
const avery: VendorDropdownItem = {
...jordan,
id: 12,
companyName: "Coastal HVAC",
contactName: "Avery Chen",
tradeSpecialties: "HVAC",
};
function renderAssigned(onSave = vi.fn()) {
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
techPhone="(314) 555-0100"
vendors={[jordan, casey]}
onSave={onSave}
/>,
);
return onSave;
}
describe("VendorDialog save gate", () => {
it("does not persist when the empty modal is saved unchanged", () => {
const onSave = vi.fn();
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId=""
company=""
tech=""
vendors={[]}
onSave={onSave}
/>,
);
expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled();
const save = screen.getByRole("button", { name: /^save$/i });
expect(save).not.toBeDisabled();
expect(save).toHaveAttribute("aria-disabled", "true");
expect(save).toHaveStyle({ opacity: "0.5", cursor: "not-allowed" });
fireEvent.click(save);
expect(onSave).not.toHaveBeenCalled();
});
it("does not persist when an existing assignment is saved unchanged", () => {
const onSave = renderAssigned();
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(onSave).not.toHaveBeenCalled();
});
it("persists after the selected technician changes", async () => {
const onSave = renderAssigned();
fireEvent.click(screen.getByRole("button", { name: /jordan lee/i }));
fireEvent.click(await screen.findByRole("button", { name: /casey new/i }));
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
vendorId: "11",
tech: "Casey New",
company: "Gateway Plumbing",
}),
);
});
});
it("persists a company-only assignment without selecting a technician", async () => {
const onSave = vi.fn();
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId=""
company=""
tech=""
vendors={[jordan, casey]}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /select company/i }));
fireEvent.click(await screen.findByRole("button", { name: "Gateway Plumbing" }));
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
vendorId: "10",
company: "Gateway Plumbing",
tech: "",
techPhone: "",
}),
);
});
it("only offers technicians from the selected company", async () => {
renderWithProviders(
<VendorDialog
open
onOpenChange={vi.fn()}
vendorId=""
company=""
tech=""
vendors={[jordan, casey, avery]}
onSave={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /select company/i }));
fireEvent.click(await screen.findByRole("button", { name: "Coastal HVAC" }));
fireEvent.click(screen.getByRole("button", { name: /select technician/i }));
expect(await screen.findByRole("button", { name: "Avery Chen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Jordan Lee" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Casey New" })).not.toBeInTheDocument();
});
it("does not persist after a notes-only change", () => {
const onSave = renderAssigned();
fireEvent.change(screen.getByPlaceholderText("Contact preferences, availability, etc."), {
target: { value: "Gate code 12" },
});
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -78,7 +78,7 @@ describe("VendorDialog stable technician identity", () => {
// Draft stays staged: add form collapses and select still shows the colliding name. // Draft stays staged: add form collapses and select still shows the colliding name.
expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy();
expect(screen.getByText("Jordan Lee · Gateway Plumbing")).toBeTruthy(); expect(screen.getByRole("button", { name: "Jordan Lee" })).toBeTruthy();
fireEvent.change(screen.getByDisplayValue("3145550199"), { fireEvent.change(screen.getByDisplayValue("3145550199"), {
target: { value: "(314) 555-9999" }, target: { value: "(314) 555-9999" },
@ -151,7 +151,7 @@ describe("VendorDialog stable technician identity", () => {
}); });
fireEvent.click(screen.getByRole("button", { name: /^add$/i })); fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
expect(screen.getByText("Casey New · Gateway Plumbing")).toBeTruthy(); expect(screen.getByRole("button", { name: "Casey New" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /^save$/i })); fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
@ -175,4 +175,56 @@ 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",
});
renderWithProviders(
<VendorDialog
open={true}
onOpenChange={vi.fn()}
vendorId={10}
company="Gateway Plumbing"
tech="Jordan Lee"
techPhone="(314) 555-0100"
vendors={[existingVendor]}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
fireEvent.change(screen.getByPlaceholderText("Full name"), {
target: { value: "Casey New" },
});
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
target: { value: "3145550188" },
});
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" }));
const companyButtons = await screen.findAllByRole("button", { name: "Gateway Plumbing" });
fireEvent.click(companyButtons[companyButtons.length - 1]!);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Casey New",
phone: "3145550188",
}),
);
});
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
vendorId: "88",
tech: "Casey New",
}),
);
});
}); });

View file

@ -7,6 +7,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { import {
applyAssignedToTableSave, applyAssignedToTableSave,
applyVendorTableSave,
workOrderSavedMessage, workOrderSavedMessage,
} from "@/domain/work-orders/utils/work-order-feedback-toasts"; } from "@/domain/work-orders/utils/work-order-feedback-toasts";
@ -94,4 +95,38 @@ describe("useWorkOrderTableMutations assigned-to toast", () => {
await waitFor(() => expect(toast.error).toHaveBeenCalled()); await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(toast.success).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled();
}); });
it("replaces the raw DispatchRequired validation string with user-facing copy", async () => {
const { BoardPatchValidationError } =
await import("@/domain/work-orders/errors/board-patch-errors");
patchBoardField.mockRejectedValue(
new BoardPatchValidationError(
"DispatchRequired",
"A primary dispatch is required. Set vendorId first or provide primaryDispatchId.",
),
);
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: () => undefined,
clearPatch: () => undefined,
getRow: () => ROW,
}),
{ wrapper: makeWrapper() },
);
act(() => {
applyVendorTableSave(result.current.patchField, 7, {
vendorId: "",
company: "",
tech: "",
techPhone: "",
});
});
await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
expect(toast.error).toHaveBeenCalledWith("Select a vendor company before saving.");
expect(toast.error).not.toHaveBeenCalledWith(expect.stringContaining("primaryDispatchId"));
});
}); });