mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): defer technician create until vendor dialog save
This commit is contained in:
parent
ed9c380efb
commit
bfee557070
4 changed files with 101 additions and 88 deletions
|
|
@ -7,16 +7,15 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
import { vendorSchema } from "@/domain/vendors/schemas/vendor-schema";
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
|
import { buildTechnicianCreatePayload } from "@/domain/vendors/utils/build-technician-create-payload";
|
||||||
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
import { createDraftVendorDropdownItem } from "@/domain/vendors/utils/draft-vendor";
|
||||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
|
||||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
|
||||||
type VendorDialogAddTechnicianProps = {
|
type VendorDialogAddTechnicianProps = {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyVendor: VendorDropdownItem;
|
companyVendor: VendorDropdownItem;
|
||||||
onCreated: (vendor: VendorDropdownItem, phone: string) => void;
|
onCreated: (vendor: VendorDropdownItem, phone: string, payload: VendorFormValues) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorDialogAddTechnician({
|
export function VendorDialogAddTechnician({
|
||||||
|
|
@ -24,7 +23,6 @@ export function VendorDialogAddTechnician({
|
||||||
companyVendor,
|
companyVendor,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: VendorDialogAddTechnicianProps) {
|
}: VendorDialogAddTechnicianProps) {
|
||||||
const createVendor = useCreateVendor();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [contactName, setContactName] = useState("");
|
const [contactName, setContactName] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
|
|
@ -36,7 +34,7 @@ export function VendorDialogAddTechnician({
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleAdd = () => {
|
||||||
const payload = buildTechnicianCreatePayload({
|
const payload = buildTechnicianCreatePayload({
|
||||||
companyVendor,
|
companyVendor,
|
||||||
contactName,
|
contactName,
|
||||||
|
|
@ -55,17 +53,10 @@ export function VendorDialogAddTechnician({
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
createVendor.mutate(parsed.data, {
|
const draftItem = createDraftVendorDropdownItem(parsed.data);
|
||||||
onSuccess: (created) => {
|
onCreated(draftItem, parsed.data.phone || phone.trim(), parsed.data);
|
||||||
const dropdownItem = vendorToDropdownItem(created);
|
resetForm();
|
||||||
onCreated(dropdownItem, created.phone || phone.trim());
|
setExpanded(false);
|
||||||
resetForm();
|
|
||||||
setExpanded(false);
|
|
||||||
},
|
|
||||||
onError: (mutationError: Error) => {
|
|
||||||
setError(mutationError.message || "Failed to create technician");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
|
|
@ -123,7 +114,6 @@ export function VendorDialogAddTechnician({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
disabled={createVendor.isPending}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetForm();
|
resetForm();
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
|
|
@ -133,10 +123,10 @@ export function VendorDialogAddTechnician({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
disabled={createVendor.isPending || contactName.trim() === ""}
|
disabled={contactName.trim() === ""}
|
||||||
onClick={handleCreate}
|
onClick={handleAdd}
|
||||||
>
|
>
|
||||||
{createVendor.isPending ? "Creating…" : "Create"}
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ 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 type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||||
|
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
|
||||||
|
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
import {
|
import {
|
||||||
buildTechnicianOptions,
|
buildTechnicianOptions,
|
||||||
|
|
@ -48,10 +52,13 @@ export function VendorDialog({
|
||||||
vendors,
|
vendors,
|
||||||
onSave,
|
onSave,
|
||||||
}: VendorDialogProps) {
|
}: VendorDialogProps) {
|
||||||
|
const createVendor = useCreateVendor();
|
||||||
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 [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||||
|
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const allVendors = useMemo(
|
const allVendors = useMemo(
|
||||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||||
|
|
@ -69,6 +76,8 @@ export function VendorDialog({
|
||||||
setPhone(techPhone || phoneForOption(tech, technicianOptions));
|
setPhone(techPhone || phoneForOption(tech, technicianOptions));
|
||||||
setNotes("");
|
setNotes("");
|
||||||
setAddedVendors([]);
|
setAddedVendors([]);
|
||||||
|
setPendingCreates({});
|
||||||
|
setSaveError(null);
|
||||||
}
|
}
|
||||||
}, [open, tech, techPhone, technicianOptions]);
|
}, [open, tech, techPhone, technicianOptions]);
|
||||||
|
|
||||||
|
|
@ -94,12 +103,50 @@ export function VendorDialog({
|
||||||
setPhone(phoneForOption(name, technicianOptions));
|
setPhone(phoneForOption(name, technicianOptions));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTechnicianCreated = (created: VendorDropdownItem, createdPhone: string) => {
|
const handleTechnicianCreated = (
|
||||||
|
created: VendorDropdownItem,
|
||||||
|
createdPhone: string,
|
||||||
|
payload: VendorFormValues,
|
||||||
|
) => {
|
||||||
setAddedVendors((current) => [...current, created]);
|
setAddedVendors((current) => [...current, created]);
|
||||||
|
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||||
setT(created.contactName);
|
setT(created.contactName);
|
||||||
setPhone(createdPhone);
|
setPhone(createdPhone);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveSaveVendor = () =>
|
||||||
|
allVendors.find(
|
||||||
|
(vendor) =>
|
||||||
|
vendor.contactName === t && vendor.companyName === (resolvedCompany || anchorCompanyName),
|
||||||
|
) ??
|
||||||
|
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||||
|
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);
|
||||||
|
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: t,
|
||||||
|
techPhone: phone,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -186,35 +233,27 @@ export function VendorDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Boolean(saveError) && (
|
||||||
|
<Text as="p" variant="caption" sx={{ fontSize: 11.5, color: "var(--color-destructive)" }}>
|
||||||
|
{saveError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
|
disabled={createVendor.isPending}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
onClick={() => {
|
disabled={createVendor.isPending}
|
||||||
const saveVendor =
|
onClick={() => void handleSave()}
|
||||||
allVendors.find(
|
|
||||||
(vendor) =>
|
|
||||||
vendor.contactName === t &&
|
|
||||||
vendor.companyName === (resolvedCompany || anchorCompanyName),
|
|
||||||
) ??
|
|
||||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
|
||||||
selectedVendor;
|
|
||||||
onSave({
|
|
||||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
|
||||||
company: resolvedCompany || anchorCompanyName,
|
|
||||||
tech: t,
|
|
||||||
techPhone: phone,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Save
|
{createVendor.isPending ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const mutate = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
|
|
||||||
useCreateVendor: () => ({
|
|
||||||
mutate,
|
|
||||||
isPending: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const companyVendor: VendorDropdownItem = {
|
const companyVendor: VendorDropdownItem = {
|
||||||
id: 10,
|
id: 10,
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
|
|
@ -24,31 +16,9 @@ const companyVendor: VendorDropdownItem = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("VendorDialogAddTechnician", () => {
|
describe("VendorDialogAddTechnician", () => {
|
||||||
it("creates a technician under the company and reports selection on success", async () => {
|
it("stages a draft technician locally without calling the create API", () => {
|
||||||
const onCreated = vi.fn();
|
const onCreated = vi.fn();
|
||||||
|
|
||||||
mutate.mockImplementation((_payload, options) => {
|
|
||||||
options?.onSuccess?.({
|
|
||||||
id: 55,
|
|
||||||
companyId: "co-1",
|
|
||||||
companyName: "Gateway Plumbing",
|
|
||||||
contactName: "Jordan Lee",
|
|
||||||
email: "dispatch@gateway.test",
|
|
||||||
phone: "(314) 555-0199",
|
|
||||||
companyPhone: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
totalJobs: 0,
|
|
||||||
address: "1 Main St",
|
|
||||||
city: "St. Louis",
|
|
||||||
state: "MO",
|
|
||||||
zip: "63101",
|
|
||||||
tradeSpecialties: "Plumbing",
|
|
||||||
isActive: true,
|
|
||||||
preferredContact: "Phone",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<VendorDialogAddTechnician
|
<VendorDialogAddTechnician
|
||||||
companyName="Gateway Plumbing"
|
companyName="Gateway Plumbing"
|
||||||
|
|
@ -64,25 +34,18 @@ describe("VendorDialogAddTechnician", () => {
|
||||||
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
target: { value: "3145550199" },
|
target: { value: "3145550199" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||||
expect(mutate).toHaveBeenCalledWith(
|
const [draftVendor, phone, payload] = onCreated.mock.calls[0] ?? [];
|
||||||
expect.objectContaining({
|
expect(isDraftVendorId(draftVendor.id)).toBe(true);
|
||||||
companyName: "Gateway Plumbing",
|
expect(draftVendor.contactName).toBe("Jordan Lee");
|
||||||
contactName: "Jordan Lee",
|
expect(phone).toBeTruthy();
|
||||||
}),
|
expect(payload).toEqual(
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onCreated).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 55,
|
|
||||||
contactName: "Jordan Lee",
|
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
}),
|
}),
|
||||||
"(314) 555-0199",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue