mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +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";
|
||||
|
||||
type VendorCellProps = {
|
||||
vendorId?: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone?: string;
|
||||
|
|
@ -13,7 +14,15 @@ type VendorCellProps = {
|
|||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
|
||||
export function VendorCell({
|
||||
vendorId = "",
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
q,
|
||||
onSave,
|
||||
}: VendorCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasAny = !!(tech || company);
|
||||
|
||||
|
|
@ -46,6 +55,7 @@ export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: Ven
|
|||
<VendorDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
vendorId={vendorId}
|
||||
company={company}
|
||||
tech={tech}
|
||||
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 { Wrench } from "lucide-react";
|
||||
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 { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
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 {
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} 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 = {
|
||||
vendorId: string;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
};
|
||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
type VendorDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
vendorId?: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone?: string;
|
||||
|
|
@ -37,43 +35,94 @@ type VendorDialogProps = {
|
|||
export function VendorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
vendorId = "",
|
||||
company,
|
||||
tech,
|
||||
techPhone = "",
|
||||
vendors,
|
||||
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(vendors, tech, company),
|
||||
[vendors, tech, company],
|
||||
() => 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, technicianOptions));
|
||||
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||
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 =
|
||||
technicianOptions.find((o) => o.name === t && (!company || o.company === company)) ??
|
||||
technicianOptions.find((o) => o.name === t || o.key === t);
|
||||
const selectedKey = selected?.key ?? "";
|
||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
||||
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);
|
||||
const name = option?.name ?? key;
|
||||
setT(name);
|
||||
setPhone(phoneForOption(name, technicianOptions));
|
||||
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,
|
||||
setSaveError,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -96,84 +145,28 @@ export function VendorDialog({
|
|||
Search and select the technician
|
||||
</Text>
|
||||
|
||||
<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={handlePick}
|
||||
/>
|
||||
</div>
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={companyVendor ?? undefined}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
/>
|
||||
|
||||
{Boolean(t) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<input
|
||||
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>
|
||||
<VendorDialogFooter
|
||||
isPending={createVendor.isPending}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export function WoTableInlineRowCells({
|
|||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<VendorCell
|
||||
vendorId={draft.vendorId}
|
||||
company={draft.company}
|
||||
tech={draft.tech}
|
||||
techPhone={draft.techPhone}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function WoTableRowServiceCells({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VendorCell
|
||||
vendorId={row.vendorId}
|
||||
company={row.company}
|
||||
tech={row.tech}
|
||||
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