mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 21:42:06 +00:00
feat(work-orders): draft technician until vendor dialog save (SH-193) (#89)
* docs(work-orders): add pending BE contract checklist for design parity gaps * feat(work-orders): add technician affordance in wo vendor dialog * fix(work-orders): defer technician create until vendor dialog save * docs(work-orders): align pr description with jira and qa residuals * fix(vendors): harden draft technician optional field typing * refactor(workorders): extract vendor dialog body footer and save hook * fix(work-orders): coerce null company vendor for add-technician prop * fix(work-orders): preserve draft technician staging on save [SH-193] * fix(work-orders): select technicians by stable id [SH-193] Keep selectedKey as explicit dialog state (SiteDialog pattern) so same-name drafts do not collide with existing vendors, and cover the regression with interaction tests. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
This commit is contained in:
parent
9c7b42e96b
commit
a30ac73d29
19 changed files with 1043 additions and 106 deletions
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board rows often have tech/company but no vendorId — resolve via vendors list.
|
||||||
|
* Prefers a stable vendor id over name+company matching.
|
||||||
|
*/
|
||||||
|
export function resolveTechnicianKey(
|
||||||
|
vendorId: string | number,
|
||||||
|
tech: string,
|
||||||
|
company: string,
|
||||||
|
vendors: VendorDropdownItem[],
|
||||||
|
): string {
|
||||||
|
const idStr = vendorId !== "" && vendorId != null ? String(vendorId) : "";
|
||||||
|
if (idStr && vendors.some((vendor) => String(vendor.id) === idStr)) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
if (idStr && vendors.length === 0) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = tech.trim();
|
||||||
|
if (!name) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byNameAndCompany =
|
||||||
|
vendors.find((vendor) => vendor.contactName === name && vendor.companyName === company) ??
|
||||||
|
vendors.find((vendor) => vendor.contactName === name);
|
||||||
|
|
||||||
|
if (byNameAndCompany) {
|
||||||
|
return String(byNameAndCompany.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphan tech string used as option key when no vendor row matches (legacy board cells).
|
||||||
|
return idStr || name;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type ResolveVendorCompanyAnchorInput = {
|
||||||
|
company: string;
|
||||||
|
resolvedCompany: string;
|
||||||
|
selectedVendor: VendorDropdownItem | undefined;
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveVendorCompanyAnchor({
|
||||||
|
company,
|
||||||
|
resolvedCompany,
|
||||||
|
selectedVendor,
|
||||||
|
vendors,
|
||||||
|
}: ResolveVendorCompanyAnchorInput): {
|
||||||
|
companyName: string;
|
||||||
|
companyVendor: VendorDropdownItem | null;
|
||||||
|
} {
|
||||||
|
const companyName = resolvedCompany || company;
|
||||||
|
if (!companyName) {
|
||||||
|
return { companyName: "", companyVendor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyVendor =
|
||||||
|
selectedVendor ??
|
||||||
|
vendors.find((vendor) => vendor.companyName === companyName) ??
|
||||||
|
({
|
||||||
|
id: "",
|
||||||
|
companyName,
|
||||||
|
contactName: "",
|
||||||
|
tradeSpecialties: "",
|
||||||
|
address: "",
|
||||||
|
distanceMiles: null,
|
||||||
|
} satisfies VendorDropdownItem);
|
||||||
|
|
||||||
|
return { companyName, companyVendor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeVendorDropdownItems(
|
||||||
|
vendors: VendorDropdownItem[],
|
||||||
|
addedVendors: VendorDropdownItem[],
|
||||||
|
): VendorDropdownItem[] {
|
||||||
|
const byId = new Map<string, VendorDropdownItem>();
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
if (vendor.id === "") continue;
|
||||||
|
byId.set(String(vendor.id), vendor);
|
||||||
|
}
|
||||||
|
for (const vendor of addedVendors) {
|
||||||
|
if (vendor.id === "") continue;
|
||||||
|
byId.set(String(vendor.id), vendor);
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||||
|
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
|
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
|
|
||||||
|
type UseVendorDialogSaveArgs = {
|
||||||
|
allVendors: VendorDropdownItem[];
|
||||||
|
pendingCreates: Record<string, VendorFormValues>;
|
||||||
|
selectedKey: string;
|
||||||
|
selectedVendor: VendorDropdownItem | undefined;
|
||||||
|
resolvedCompany: string;
|
||||||
|
anchorCompanyName: string;
|
||||||
|
tech: string;
|
||||||
|
phone: string;
|
||||||
|
createVendor: ReturnType<typeof useCreateVendor>;
|
||||||
|
onSave: (patch: VendorPatch) => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
setSaveError: (error: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useVendorDialogSave({
|
||||||
|
allVendors,
|
||||||
|
pendingCreates,
|
||||||
|
selectedKey,
|
||||||
|
selectedVendor,
|
||||||
|
resolvedCompany,
|
||||||
|
anchorCompanyName,
|
||||||
|
tech,
|
||||||
|
phone,
|
||||||
|
createVendor,
|
||||||
|
onSave,
|
||||||
|
onOpenChange,
|
||||||
|
setSaveError,
|
||||||
|
}: UseVendorDialogSaveArgs) {
|
||||||
|
const resolveSaveVendor = () =>
|
||||||
|
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||||
|
allVendors.find(
|
||||||
|
(vendor) =>
|
||||||
|
vendor.contactName === tech &&
|
||||||
|
vendor.companyName === (resolvedCompany || anchorCompanyName),
|
||||||
|
) ??
|
||||||
|
selectedVendor;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaveError(null);
|
||||||
|
let saveVendor = resolveSaveVendor();
|
||||||
|
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||||
|
|
||||||
|
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
|
||||||
|
try {
|
||||||
|
const created = await createVendor.mutateAsync({
|
||||||
|
...pendingPayload,
|
||||||
|
phone: phone.trim() || pendingPayload.phone,
|
||||||
|
});
|
||||||
|
saveVendor = vendorToDropdownItem(created);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to create technician";
|
||||||
|
setSaveError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||||
|
company: resolvedCompany || anchorCompanyName,
|
||||||
|
tech: saveVendor?.contactName ?? tech,
|
||||||
|
techPhone: phone,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleSave };
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { HoverChevron } from "./hover-chevron";
|
||||||
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
|
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
|
||||||
|
|
||||||
type VendorCellProps = {
|
type VendorCellProps = {
|
||||||
|
vendorId?: string | number;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
|
|
@ -13,7 +14,15 @@ type VendorCellProps = {
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
|
export function VendorCell({
|
||||||
|
vendorId = "",
|
||||||
|
company,
|
||||||
|
tech,
|
||||||
|
techPhone,
|
||||||
|
vendors,
|
||||||
|
q,
|
||||||
|
onSave,
|
||||||
|
}: VendorCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const hasAny = !!(tech || company);
|
const hasAny = !!(tech || company);
|
||||||
|
|
||||||
|
|
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
|
||||||
<VendorDialog
|
<VendorDialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
|
vendorId={vendorId}
|
||||||
company={company}
|
company={company}
|
||||||
tech={tech}
|
tech={tech}
|
||||||
techPhone={techPhone}
|
techPhone={techPhone}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
|
||||||
|
import { createDraftVendorDropdownItem } from "@/domain/vendors/utils/draft-vendor";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
|
||||||
|
type VendorDialogAddTechnicianProps = {
|
||||||
|
companyName: string;
|
||||||
|
companyVendor: VendorDropdownItem;
|
||||||
|
onCreated: (vendor: VendorDropdownItem, phone: string, payload: VendorFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorDialogAddTechnician({
|
||||||
|
companyName,
|
||||||
|
companyVendor,
|
||||||
|
onCreated,
|
||||||
|
}: VendorDialogAddTechnicianProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [contactName, setContactName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setContactName("");
|
||||||
|
setPhone("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const payload = buildTechnicianCreatePayload({
|
||||||
|
companyVendor,
|
||||||
|
contactName,
|
||||||
|
phone,
|
||||||
|
});
|
||||||
|
const parsed = vendorSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const fieldErrors = parsed.error.flatten().fieldErrors;
|
||||||
|
const message =
|
||||||
|
fieldErrors.contactName?.[0] ??
|
||||||
|
fieldErrors.phone?.[0] ??
|
||||||
|
fieldErrors.companyName?.[0] ??
|
||||||
|
"Enter a valid technician name";
|
||||||
|
setError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
const draftItem = createDraftVendorDropdownItem(parsed.data);
|
||||||
|
onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data);
|
||||||
|
resetForm();
|
||||||
|
setExpanded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<Plus size={12} className="shrink-0" aria-hidden />+ Add technician
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-2 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text as="p" variant="caption" sx={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||||
|
New technician for {companyName}
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician Name</FieldLabel>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={contactName}
|
||||||
|
onChange={(e) => setContactName(e.target.value)}
|
||||||
|
placeholder="Full name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician Phone</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="(000) 000-0000"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Boolean(error) && (
|
||||||
|
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setExpanded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
|
disabled={contactName.trim() === ""}
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { Wrench } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||||
|
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||||
|
|
||||||
|
type VendorDialogBodyProps = {
|
||||||
|
selectedKey: string;
|
||||||
|
technicianOptions: TechnicianOption[];
|
||||||
|
anchorCompanyName: string;
|
||||||
|
companyVendorForAdd: VendorDropdownItem | undefined;
|
||||||
|
tech: string;
|
||||||
|
phone: string;
|
||||||
|
notes: string;
|
||||||
|
resolvedCompany: string;
|
||||||
|
selectedVendor: VendorDropdownItem | undefined;
|
||||||
|
saveError: string | null;
|
||||||
|
onPick: (key: string) => void;
|
||||||
|
onPhoneChange: (phone: string) => void;
|
||||||
|
onNotesChange: (notes: string) => void;
|
||||||
|
onTechnicianCreated: (
|
||||||
|
created: VendorDropdownItem,
|
||||||
|
createdPhone: string,
|
||||||
|
payload: VendorFormValues,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorDialogBody({
|
||||||
|
selectedKey,
|
||||||
|
technicianOptions,
|
||||||
|
anchorCompanyName,
|
||||||
|
companyVendorForAdd,
|
||||||
|
tech,
|
||||||
|
phone,
|
||||||
|
notes,
|
||||||
|
resolvedCompany,
|
||||||
|
selectedVendor,
|
||||||
|
saveError,
|
||||||
|
onPick,
|
||||||
|
onPhoneChange,
|
||||||
|
onNotesChange,
|
||||||
|
onTechnicianCreated,
|
||||||
|
}: VendorDialogBodyProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician</FieldLabel>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={selectedKey}
|
||||||
|
placeholder="Search technician …"
|
||||||
|
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,
|
||||||
|
}))}
|
||||||
|
onPick={onPick}
|
||||||
|
/>
|
||||||
|
{companyVendorForAdd != null && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<VendorDialogAddTechnician
|
||||||
|
companyName={anchorCompanyName}
|
||||||
|
companyVendor={companyVendorForAdd}
|
||||||
|
onCreated={onTechnicianCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(tech) && (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician Phone</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => onPhoneChange(e.target.value)}
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(resolvedCompany) && (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Company</FieldLabel>
|
||||||
|
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Notes</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => onNotesChange(e.target.value)}
|
||||||
|
placeholder="Contact preferences, availability, etc."
|
||||||
|
rows={3}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(saveError) && (
|
||||||
|
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
|
||||||
|
{saveError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type VendorDialogFooterProps = {
|
||||||
|
isPending: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorDialogFooter({ isPending, onCancel, onSave }: VendorDialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => void onSave()}
|
||||||
|
>
|
||||||
|
{isPending ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export type VendorPatch = {
|
||||||
|
vendorId: string;
|
||||||
|
company: string;
|
||||||
|
tech: string;
|
||||||
|
techPhone: string;
|
||||||
|
};
|
||||||
|
|
@ -1,32 +1,30 @@
|
||||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import { Wrench } from "lucide-react";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
|
||||||
import {
|
|
||||||
DLG_INPUT_CLS,
|
|
||||||
DLG_INPUT_STYLE,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { 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 { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
import {
|
import {
|
||||||
buildTechnicianOptions,
|
buildTechnicianOptions,
|
||||||
phoneForOption,
|
phoneForOption,
|
||||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
import { 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 type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
|
|
||||||
export type VendorPatch = {
|
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
vendorId: string;
|
|
||||||
company: string;
|
|
||||||
tech: string;
|
|
||||||
techPhone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VendorDialogProps = {
|
type VendorDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
vendorId?: string | number;
|
||||||
company: string;
|
company: string;
|
||||||
tech: string;
|
tech: string;
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
|
|
@ -37,43 +35,94 @@ type VendorDialogProps = {
|
||||||
export function VendorDialog({
|
export function VendorDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
vendorId = "",
|
||||||
company,
|
company,
|
||||||
tech,
|
tech,
|
||||||
techPhone = "",
|
techPhone = "",
|
||||||
vendors,
|
vendors,
|
||||||
onSave,
|
onSave,
|
||||||
}: VendorDialogProps) {
|
}: VendorDialogProps) {
|
||||||
|
const createVendor = useCreateVendor();
|
||||||
|
const [selectedKey, setSelectedKey] = useState(() =>
|
||||||
|
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||||
|
);
|
||||||
const [t, setT] = useState(tech);
|
const [t, setT] = useState(tech);
|
||||||
const [phone, setPhone] = useState(techPhone);
|
const [phone, setPhone] = useState(techPhone);
|
||||||
const [notes, setNotes] = useState("");
|
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(
|
const technicianOptions = useMemo(
|
||||||
() => buildTechnicianOptions(vendors, tech, company),
|
() => buildTechnicianOptions(allVendors, tech, company),
|
||||||
[vendors, tech, company],
|
[allVendors, tech, company],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||||
|
setSelectedKey(key);
|
||||||
setT(tech);
|
setT(tech);
|
||||||
setPhone(techPhone || phoneForOption(tech, technicianOptions));
|
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setAddedVendors([]);
|
||||||
|
setPendingCreates({});
|
||||||
|
setSaveError(null);
|
||||||
}
|
}
|
||||||
}, [open, tech, techPhone, technicianOptions]);
|
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
||||||
|
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||||
|
|
||||||
const selected =
|
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||||
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
|
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
||||||
technicianOptions.find((o) => o.name === t || o.key === t);
|
const selectedVendor =
|
||||||
const selectedKey = selected?.key ?? "";
|
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
||||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
|
||||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||||
|
company,
|
||||||
|
resolvedCompany,
|
||||||
|
selectedVendor,
|
||||||
|
vendors: allVendors,
|
||||||
|
});
|
||||||
|
|
||||||
const handlePick = (key: string) => {
|
const handlePick = (key: string) => {
|
||||||
const option = technicianOptions.find((o) => o.key === key);
|
const option = technicianOptions.find((o) => o.key === key);
|
||||||
const name = option?.name ?? key;
|
setSelectedKey(key);
|
||||||
setT(name);
|
setT(option?.name ?? key);
|
||||||
setPhone(phoneForOption(name, technicianOptions));
|
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,
|
||||||
|
setSaveError,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -96,84 +145,28 @@ export function VendorDialog({
|
||||||
Search and select the technician
|
Search and select the technician
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mt-3 space-y-3">
|
<VendorDialogBody
|
||||||
<div>
|
selectedKey={selectedKey}
|
||||||
<FieldLabel>Technician</FieldLabel>
|
technicianOptions={technicianOptions}
|
||||||
<WizardFieldSelect
|
anchorCompanyName={anchorCompanyName}
|
||||||
value={selectedKey}
|
companyVendorForAdd={companyVendor ?? undefined}
|
||||||
placeholder="Search technician …"
|
tech={selected?.name ?? t}
|
||||||
icon={
|
phone={phone}
|
||||||
<Wrench
|
notes={notes}
|
||||||
size={14}
|
resolvedCompany={resolvedCompany}
|
||||||
className="shrink-0"
|
selectedVendor={selectedVendor}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
saveError={saveError}
|
||||||
/>
|
onPick={handlePick}
|
||||||
}
|
onPhoneChange={setPhone}
|
||||||
searchPlaceholder="Search technician…"
|
onNotesChange={setNotes}
|
||||||
options={technicianOptions.map((option) => ({
|
onTechnicianCreated={handleTechnicianCreated}
|
||||||
key: option.key,
|
/>
|
||||||
label: option.label,
|
|
||||||
}))}
|
|
||||||
onPick={handlePick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Boolean(t) && (
|
<VendorDialogFooter
|
||||||
<div>
|
isPending={createVendor.isPending}
|
||||||
<FieldLabel>Technician Phone</FieldLabel>
|
onCancel={() => onOpenChange(false)}
|
||||||
<input
|
onSave={handleSave}
|
||||||
type="tel"
|
/>
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(t) && (
|
|
||||||
<div>
|
|
||||||
<FieldLabel>Company</FieldLabel>
|
|
||||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FieldLabel>Notes</FieldLabel>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
placeholder="Contact preferences, availability, etc."
|
|
||||||
rows={3}
|
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
|
||||||
onClick={() => {
|
|
||||||
onSave({
|
|
||||||
vendorId: selectedVendor ? String(selectedVendor.id) : "",
|
|
||||||
company: resolvedCompany,
|
|
||||||
tech: t,
|
|
||||||
techPhone: phone,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ export function WoTableInlineRowCells({
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
vendorId={draft.vendorId}
|
||||||
company={draft.company}
|
company={draft.company}
|
||||||
tech={draft.tech}
|
tech={draft.tech}
|
||||||
techPhone={draft.techPhone}
|
techPhone={draft.techPhone}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export function WoTableRowServiceCells({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<VendorCell
|
<VendorCell
|
||||||
|
vendorId={row.vendorId}
|
||||||
company={row.company}
|
company={row.company}
|
||||||
tech={row.tech}
|
tech={row.tech}
|
||||||
techPhone={row.techPhone}
|
techPhone={row.techPhone}
|
||||||
|
|
|
||||||
36
src/domain/vendors/utils/build-technician-create-payload.ts
vendored
Normal file
36
src/domain/vendors/utils/build-technician-create-payload.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
|
type BuildTechnicianCreatePayloadInput = {
|
||||||
|
companyVendor: Pick<VendorDropdownItem, "companyName" | "email" | "address" | "tradeSpecialties">;
|
||||||
|
contactName: string;
|
||||||
|
phone?: string;
|
||||||
|
companyId?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Builds a vendor create payload for a new technician under an existing company. */
|
||||||
|
export function buildTechnicianCreatePayload({
|
||||||
|
companyVendor,
|
||||||
|
contactName,
|
||||||
|
phone = "",
|
||||||
|
companyId = null,
|
||||||
|
}: BuildTechnicianCreatePayloadInput): VendorFormValues {
|
||||||
|
return {
|
||||||
|
companyName: companyVendor.companyName,
|
||||||
|
contactName: contactName.trim(),
|
||||||
|
email: companyVendor.email ?? "",
|
||||||
|
phone: phone.trim(),
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: companyVendor.address ?? "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: companyVendor.tradeSpecialties ?? "",
|
||||||
|
isActive: true,
|
||||||
|
companyId,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/domain/vendors/utils/draft-vendor.ts
vendored
Normal file
21
src/domain/vendors/utils/draft-vendor.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
|
export const DRAFT_VENDOR_ID_PREFIX = "draft:";
|
||||||
|
|
||||||
|
export function isDraftVendorId(id: string | number | null | undefined): boolean {
|
||||||
|
return String(id ?? "").startsWith(DRAFT_VENDOR_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraftVendorDropdownItem(payload: VendorFormValues): VendorDropdownItem {
|
||||||
|
const email = (payload.email ?? "").trim();
|
||||||
|
return {
|
||||||
|
id: `${DRAFT_VENDOR_ID_PREFIX}${crypto.randomUUID()}`,
|
||||||
|
companyName: payload.companyName,
|
||||||
|
contactName: payload.contactName,
|
||||||
|
tradeSpecialties: payload.tradeSpecialties ?? "",
|
||||||
|
address: payload.address ?? "",
|
||||||
|
distanceMiles: null,
|
||||||
|
email: email || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/domain/vendors/utils/vendor-to-dropdown-item.ts
vendored
Normal file
15
src/domain/vendors/utils/vendor-to-dropdown-item.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
export function vendorToDropdownItem(vendor: Vendor): VendorDropdownItem {
|
||||||
|
const email = vendor.email.trim();
|
||||||
|
return {
|
||||||
|
id: vendor.id ?? "",
|
||||||
|
companyName: vendor.companyName,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
|
address: vendor.address,
|
||||||
|
distanceMiles: null,
|
||||||
|
email: email || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
const vendors: VendorDropdownItem[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("resolveTechnicianKey", () => {
|
||||||
|
it("prefers a stable vendorId when it exists in the list", () => {
|
||||||
|
expect(resolveTechnicianKey(11, "Jordan Lee", "Gateway Plumbing", vendors)).toBe("11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to name+company when vendorId is missing", () => {
|
||||||
|
expect(resolveTechnicianKey("", "Jordan Lee", "Gateway Plumbing", vendors)).toBe("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps orphan tech string as key when no vendor matches", () => {
|
||||||
|
expect(resolveTechnicianKey("", "Unknown Tech", "Gateway Plumbing", vendors)).toBe(
|
||||||
|
"Unknown Tech",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps vendorId when the vendors list is still empty", () => {
|
||||||
|
expect(resolveTechnicianKey(42, "Jordan Lee", "Gateway Plumbing", [])).toBe("42");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const companyVendor: VendorDropdownItem = {
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("VendorDialogAddTechnician", () => {
|
||||||
|
it("stages a draft technician locally without calling the create API", () => {
|
||||||
|
const onCreated = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialogAddTechnician
|
||||||
|
companyName="Gateway Plumbing"
|
||||||
|
companyVendor={companyVendor}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Full name"), {
|
||||||
|
target: { value: "Jordan Lee" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
|
target: { value: "3145550199" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
|
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||||
|
const [draftVendor, phone, payload] = onCreated.mock.calls[0] ?? [];
|
||||||
|
expect(isDraftVendorId(draftVendor.id)).toBe(true);
|
||||||
|
expect(draftVendor.contactName).toBe("Jordan Lee");
|
||||||
|
expect(phone).toBeTruthy();
|
||||||
|
expect(payload).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||||
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const mutateAsync = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
|
||||||
|
useCreateVendor: () => ({
|
||||||
|
mutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existingVendor: VendorDropdownItem = {
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdVendor: Vendor = {
|
||||||
|
id: 99,
|
||||||
|
companyId: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
phone: "(314) 555-9999",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
zip: "",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("VendorDialog stable technician identity", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mutateAsync.mockReset();
|
||||||
|
mutateAsync.mockResolvedValue(createdVendor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
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: "Jordan Lee" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
|
target: { value: "3145550199" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue("3145550199"), {
|
||||||
|
target: { value: "(314) 555-9999" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
phone: "(314) 555-9999",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "99",
|
||||||
|
tech: "Jordan Lee",
|
||||||
|
company: "Gateway Plumbing",
|
||||||
|
techPhone: "(314) 555-9999",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must not persist the pre-existing colliding vendor id.
|
||||||
|
expect(onSave.mock.calls[0]?.[0].vendorId).not.toBe("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
mutateAsync.mockResolvedValue({
|
||||||
|
...createdVendor,
|
||||||
|
id: 88,
|
||||||
|
contactName: "Casey New",
|
||||||
|
phone: "3145550188",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId={11}
|
||||||
|
company="Gateway Plumbing"
|
||||||
|
tech="Adam Whyte"
|
||||||
|
techPhone=""
|
||||||
|
vendors={[
|
||||||
|
existingVendor,
|
||||||
|
{
|
||||||
|
...existingVendor,
|
||||||
|
id: 11,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Casey New · Gateway Plumbing")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: "Casey New",
|
||||||
|
phone: "3145550188",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "88",
|
||||||
|
tech: "Casey New",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/test/domain/vendors/utils/build-technician-create-payload.test.ts
vendored
Normal file
106
src/test/domain/vendors/utils/build-technician-create-payload.test.ts
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
mergeVendorDropdownItems,
|
||||||
|
resolveVendorCompanyAnchor,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
|
||||||
|
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
||||||
|
import type { Vendor } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
describe("buildTechnicianCreatePayload", () => {
|
||||||
|
it("copies company fields and sets the new technician identity", () => {
|
||||||
|
const payload = buildTechnicianCreatePayload({
|
||||||
|
companyVendor: {
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
},
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
companyId: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
address: "1 Main St",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
companyId: 42,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveVendorCompanyAnchor", () => {
|
||||||
|
const vendors: VendorDropdownItem[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns null anchor when no company context exists", () => {
|
||||||
|
expect(
|
||||||
|
resolveVendorCompanyAnchor({
|
||||||
|
company: "",
|
||||||
|
resolvedCompany: "",
|
||||||
|
selectedVendor: undefined,
|
||||||
|
vendors,
|
||||||
|
}),
|
||||||
|
).toEqual({ companyName: "", companyVendor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges newly created vendors without dropping existing options", () => {
|
||||||
|
const added: VendorDropdownItem = {
|
||||||
|
id: 55,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: null,
|
||||||
|
};
|
||||||
|
expect(mergeVendorDropdownItems(vendors, [added])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vendorToDropdownItem", () => {
|
||||||
|
it("maps a created vendor into the work-order dropdown shape", () => {
|
||||||
|
const vendor: Vendor = {
|
||||||
|
id: 99,
|
||||||
|
companyId: "co-1",
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
email: "jordan@gateway.test",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
companyPhone: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
totalJobs: 0,
|
||||||
|
address: "1 Main St",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "63101",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
isActive: true,
|
||||||
|
preferredContact: "Phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(vendorToDropdownItem(vendor)).toEqual({
|
||||||
|
id: 99,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: null,
|
||||||
|
email: "jordan@gateway.test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tmp/pr-descriptions/pr7-add-technician.md
Normal file
27
tmp/pr-descriptions/pr7-add-technician.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
## Jira
|
||||||
|
|
||||||
|
- [SH-193](https://luby-us.atlassian.net/browse/SH-193)
|
||||||
|
|
||||||
|
## QA residual
|
||||||
|
|
||||||
|
- P0 done: technician is staged as a **local draft** until the vendor dialog is saved / wizard submitted — Cancel does not orphan a vendor row.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Adds **+ Add technician** to the WO board vendor dialog when a company context is known (from the row or the current selection).
|
||||||
|
- Stages a draft technician locally (`draft:` id) without calling `POST vendors` until the dialog save commits the selection.
|
||||||
|
- On save, create runs via the existing **`POST vendors`** REST endpoint (`vendorsApi.create` / `useCreateVendor`) using the company profile + new `contactName`.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Uses the established vendor create API (`API_PATHS.rest.vendors` → `POST /vendors`). Each vendor record represents one technician; linking to an existing company is done by sending the same `companyName` (and optional `companyId` when available). **No new endpoint**.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Open vendor dialog → **+ Add technician** → fill name → Cancel dialog → no vendor create API call.
|
||||||
|
- [ ] Add technician → Save dialog → `POST /vendors` once; row shows new tech.
|
||||||
|
- [ ] `npm run verify`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Multi-POC, multi-tech arrays on WO, vendors directory redesign.
|
||||||
Loading…
Add table
Reference in a new issue