mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +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;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
export function useVendorDialogSave({
|
||||
|
|
@ -33,6 +34,7 @@ export function useVendorDialogSave({
|
|||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
}: UseVendorDialogSaveArgs) {
|
||||
const resolveSaveVendor = () =>
|
||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||
|
|
@ -44,6 +46,7 @@ export function useVendorDialogSave({
|
|||
selectedVendor;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaveError(null);
|
||||
let saveVendor = resolveSaveVendor();
|
||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||
|
|
@ -65,8 +68,8 @@ export function useVendorDialogSave({
|
|||
onSave({
|
||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||
company: resolvedCompany || anchorCompanyName,
|
||||
tech: saveVendor?.contactName ?? tech,
|
||||
techPhone: phone,
|
||||
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
|
||||
techPhone: selectedKey ? phone : "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
import { Building2, Wrench } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
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 { 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";
|
||||
import type {
|
||||
CompanyOption,
|
||||
TechnicianOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
|
||||
type VendorDialogBodyProps = {
|
||||
selectedCompany: string;
|
||||
companyOptions: CompanyOption[];
|
||||
selectedKey: string;
|
||||
technicianOptions: TechnicianOption[];
|
||||
anchorCompanyName: string;
|
||||
|
|
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
|
|||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
saveError: string | null;
|
||||
onCompanyPick: (company: string) => void;
|
||||
onPick: (key: string) => void;
|
||||
onPhoneChange: (phone: string) => void;
|
||||
onNotesChange: (notes: string) => void;
|
||||
|
|
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
|
|||
};
|
||||
|
||||
export function VendorDialogBody({
|
||||
selectedCompany,
|
||||
companyOptions,
|
||||
selectedKey,
|
||||
technicianOptions,
|
||||
anchorCompanyName,
|
||||
|
|
@ -44,6 +52,7 @@ export function VendorDialogBody({
|
|||
resolvedCompany,
|
||||
selectedVendor,
|
||||
saveError,
|
||||
onCompanyPick,
|
||||
onPick,
|
||||
onPhoneChange,
|
||||
onNotesChange,
|
||||
|
|
@ -53,17 +62,46 @@ export function VendorDialogBody({
|
|||
<>
|
||||
<div className="mt-3 space-y-3">
|
||||
<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
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
|
||||
disabled={!selectedCompany}
|
||||
icon={
|
||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
label: option.name,
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
|
|
@ -80,7 +118,7 @@ export function VendorDialogBody({
|
|||
|
||||
{Boolean(tech) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<FieldLabel>Technician Phone (optional)</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
|
|
@ -91,15 +129,8 @@ export function VendorDialogBody({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<FieldLabel>Notes (optional)</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
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 type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
|
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||
{Boolean(vendor?.tradeSpecialties) && (
|
||||
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||
)}
|
||||
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
|
||||
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||
{vendor != null && Boolean(vendor.address) && (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
|
|||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
viewOnly?: boolean;
|
||||
canSubmit?: boolean;
|
||||
};
|
||||
|
||||
const lookDisabledSx = {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||
};
|
||||
|
||||
export function VendorDialogFooter({
|
||||
|
|
@ -12,6 +19,7 @@ export function VendorDialogFooter({
|
|||
onCancel,
|
||||
onSave,
|
||||
viewOnly = false,
|
||||
canSubmit = false,
|
||||
}: VendorDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
|
|
@ -36,7 +44,13 @@ export function VendorDialogFooter({
|
|||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
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"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,44 @@ export type TechnicianOption = {
|
|||
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(
|
||||
vendors: VendorDropdownItem[],
|
||||
selectedCompany: string,
|
||||
tech: string,
|
||||
company: string,
|
||||
currentCompany: string,
|
||||
): TechnicianOption[] {
|
||||
const options = new Map<string, TechnicianOption>();
|
||||
|
||||
|
|
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
|
|||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
if (vendor.companyName !== selectedCompany) {
|
||||
continue;
|
||||
}
|
||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||
add({
|
||||
key: String(vendor.id),
|
||||
|
|
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
|
|||
});
|
||||
}
|
||||
|
||||
if (tech) {
|
||||
if (tech && selectedCompany === currentCompany) {
|
||||
const vendor =
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||
vendors.find((v) => v.contactName === tech) ??
|
||||
vendors.find((v) => v.companyName === company);
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
|
||||
vendors.find((v) => v.contactName === tech);
|
||||
const key = vendor ? String(vendor.id) : tech;
|
||||
add({
|
||||
key,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
label: tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
company: currentCompany,
|
||||
phone: "",
|
||||
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 { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
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 { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
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 { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
|
||||
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";
|
||||
|
|
@ -44,85 +33,15 @@ export function VendorDialog({
|
|||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
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 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,
|
||||
const form = useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -134,43 +53,47 @@ export function VendorDialog({
|
|||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
style: { borderRadius: 12, maxWidth: 560 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Technician
|
||||
Vendor
|
||||
</DialogTitle>
|
||||
<Text as="p" className="sr-only">
|
||||
Search and select the technician
|
||||
Search and select the vendor company
|
||||
</Text>
|
||||
|
||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
selectedCompany={form.selectedCompany}
|
||||
companyOptions={form.companyOptions}
|
||||
selectedKey={form.selectedKey}
|
||||
technicianOptions={form.technicianOptions}
|
||||
anchorCompanyName={form.anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||
tech={form.selected?.name ?? form.t}
|
||||
phone={form.phone}
|
||||
notes={form.notes}
|
||||
resolvedCompany={form.resolvedCompany}
|
||||
selectedVendor={form.selectedVendor}
|
||||
saveError={form.saveError}
|
||||
onCompanyPick={form.handleCompanyPick}
|
||||
onPick={form.handlePick}
|
||||
onPhoneChange={form.setPhone}
|
||||
onNotesChange={form.setNotes}
|
||||
onTechnicianCreated={form.handleTechnicianCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<VendorDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
isPending={createVendor.isPending}
|
||||
isPending={form.isPending}
|
||||
canSubmit={form.canSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
onSave={form.handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
|
|||
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 { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
boardPatchValidationMessage,
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
|
|
@ -161,7 +162,7 @@ export function useWorkOrderTableMutations(
|
|||
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||
} else if (error instanceof BoardPatchValidationError) {
|
||||
clearPatch(id);
|
||||
toast.error(error.message);
|
||||
toast.error(boardPatchValidationMessage(error));
|
||||
} else {
|
||||
clearPatch(id);
|
||||
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)", () => {
|
||||
renderWithProviders(
|
||||
<>
|
||||
<VendorDialogFooter isPending={false} onCancel={() => undefined} onSave={() => undefined} />
|
||||
<VendorDialogFooter
|
||||
isPending={false}
|
||||
canSubmit={false}
|
||||
onCancel={() => undefined}
|
||||
onSave={() => undefined}
|
||||
/>
|
||||
<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.
|
||||
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"), {
|
||||
target: { value: "(314) 555-9999" },
|
||||
|
|
@ -151,7 +151,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
});
|
||||
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 }));
|
||||
|
||||
|
|
@ -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 {
|
||||
applyAssignedToTableSave,
|
||||
applyVendorTableSave,
|
||||
workOrderSavedMessage,
|
||||
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
|
||||
|
|
@ -94,4 +95,38 @@ describe("useWorkOrderTableMutations assigned-to toast", () => {
|
|||
await waitFor(() => expect(toast.error).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