mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 16:32:06 +00:00
Merge pull request #171 from Sea-Haven-Industries/fix/SH-181-vendor-save-gate
fix(workorders): correct vendor save behavior
This commit is contained in:
commit
a7d359ffc4
15 changed files with 705 additions and 143 deletions
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue