mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 04:12:09 +00:00
Merge pull request #50 from Sea-Haven-Industries/codex/vendor-core-completion-frontend
Complete vendor directory and work-order vendor editing
This commit is contained in:
commit
afb8ce67aa
26 changed files with 1269 additions and 112 deletions
87
e2e/vendors/vendors.spec.ts
vendored
87
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -13,6 +13,7 @@ const TEST_USER = {
|
||||||
const activeVendors = [
|
const activeVendors = [
|
||||||
{
|
{
|
||||||
Id: 1,
|
Id: 1,
|
||||||
|
CompanyId: 101,
|
||||||
CompanyName: "Gateway Plumbing",
|
CompanyName: "Gateway Plumbing",
|
||||||
ContactName: "Adam Whyte",
|
ContactName: "Adam Whyte",
|
||||||
Email: "dispatch@gateway.test",
|
Email: "dispatch@gateway.test",
|
||||||
|
|
@ -27,9 +28,11 @@ const activeVendors = [
|
||||||
Notes: "Preferred after-hours vendor",
|
Notes: "Preferred after-hours vendor",
|
||||||
TotalJobs: 72,
|
TotalJobs: 72,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
|
PreferredContact: "Email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Id: 2,
|
Id: 2,
|
||||||
|
CompanyId: 102,
|
||||||
CompanyName: "Metro HVAC",
|
CompanyName: "Metro HVAC",
|
||||||
ContactName: "Maya Chen",
|
ContactName: "Maya Chen",
|
||||||
Email: "maya@metro.test",
|
Email: "maya@metro.test",
|
||||||
|
|
@ -75,7 +78,10 @@ async function captureVisual(page: Page, path: string) {
|
||||||
await page.screenshot({ path, fullPage: true });
|
await page.screenshot({ path, fullPage: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockVendorApi(page: Page, options: { listError?: boolean } = {}) {
|
async function mockVendorApi(
|
||||||
|
page: Page,
|
||||||
|
options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {},
|
||||||
|
) {
|
||||||
const state: MockState = { listUrls: [] };
|
const state: MockState = { listUrls: [] };
|
||||||
|
|
||||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
|
@ -92,6 +98,7 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
||||||
fulfillJson(route, {
|
fulfillJson(route, {
|
||||||
data: {
|
data: {
|
||||||
companies: activeVendors.map((vendor) => ({
|
companies: activeVendors.map((vendor) => ({
|
||||||
|
companyId: vendor.CompanyId,
|
||||||
name: vendor.CompanyName,
|
name: vendor.CompanyName,
|
||||||
companyPhone: vendor.CompanyPhone,
|
companyPhone: vendor.CompanyPhone,
|
||||||
email: vendor.Email,
|
email: vendor.Email,
|
||||||
|
|
@ -143,6 +150,27 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
||||||
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
PageSize: Number(url.searchParams.get("pageSize") ?? 12),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => {
|
||||||
|
const vendorId = Number(route.request().url().split("/").at(-2));
|
||||||
|
await fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
vendorId,
|
||||||
|
canDeactivate: !options.deactivationBlocked,
|
||||||
|
openWorkOrders: options.deactivationBlocked
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
workOrderId: 501,
|
||||||
|
workOrderNumber: "WO-501",
|
||||||
|
workOrderTitle: "Emergency boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2026-07-24T14:00:00Z",
|
||||||
|
dispatchId: 801,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
await page.route(/\/api\/vendors\/\d+$/, async (route) => {
|
||||||
const id = route.request().url().split("/").pop() ?? "";
|
const id = route.request().url().split("/").pop() ?? "";
|
||||||
if (route.request().method() === "PUT") {
|
if (route.request().method() === "PUT") {
|
||||||
|
|
@ -153,6 +181,14 @@ async function mockVendorApi(page: Page, options: { listError?: boolean } = {})
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
|
if (options.deleteConflict) {
|
||||||
|
await fulfillJson(
|
||||||
|
route,
|
||||||
|
{ message: "Vendor has open work orders and cannot be deactivated." },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.deletedId = id;
|
state.deletedId = id;
|
||||||
await fulfillJson(route, { status: "200" });
|
await fulfillJson(route, { status: "200" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -248,15 +284,13 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||||
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible();
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||||
await expect(page.getByText("Technician name is required")).toBeVisible();
|
|
||||||
await expect(page.getByText("Company is required")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByLabel("Technician Name").fill("New Technician");
|
await page.getByLabel("Technician Name").fill("New Technician");
|
||||||
await page.getByRole("combobox", { name: "Company" }).click();
|
await page.getByRole("combobox", { name: "Company" }).click();
|
||||||
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
||||||
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||||
await expect(page.getByLabel("Email")).toHaveValue("dispatch@gateway.test");
|
await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test");
|
||||||
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||||
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||||
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing");
|
||||||
|
|
@ -265,12 +299,19 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await page.getByRole("button", { name: "Add trade" }).click();
|
await page.getByRole("button", { name: "Add trade" }).click();
|
||||||
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
await page.getByRole("button", { name: "Move HVAC up" }).click();
|
||||||
await page.getByLabel("Notes").fill("Created in browser E2E");
|
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||||
|
await page.getByRole("button", { name: "Text" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0);
|
||||||
expect(state.createdBody).toMatchObject({
|
expect(state.createdBody).toMatchObject({
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
|
companyId: 101,
|
||||||
contactName: "New Technician",
|
contactName: "New Technician",
|
||||||
companyPhone: "314-555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
|
preferredContact: "Text",
|
||||||
tradeSpecialties: "HVAC, Plumbing",
|
tradeSpecialties: "HVAC, Plumbing",
|
||||||
notes: "Created in browser E2E",
|
notes: "Created in browser E2E",
|
||||||
});
|
});
|
||||||
|
|
@ -308,11 +349,45 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(addButton).toBeFocused();
|
await expect(addButton).toBeFocused();
|
||||||
|
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
|
await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled();
|
||||||
await page.getByLabel("Technician Name").fill("Independent Tech");
|
await page.getByLabel("Technician Name").fill("Independent Tech");
|
||||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
await page.getByRole("button", { name: "Add Vendor" }).last().click();
|
||||||
|
|
||||||
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC");
|
||||||
|
expect(state.createdBody?.companyId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||||
|
await page.goto("/vendors");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||||
|
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||||
|
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
expect(blockedState.deletedId).toBeUndefined();
|
||||||
|
|
||||||
|
await blockedDialog.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await page.unrouteAll({ behavior: "wait" });
|
||||||
|
|
||||||
|
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /^Deactivate$/,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||||
|
/open work orders|conflict/i,
|
||||||
|
);
|
||||||
|
expect(racedState.deletedId).toBeUndefined();
|
||||||
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => {
|
||||||
|
|
|
||||||
90
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
90
e2e/work-orders/vendor-inline-editor.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { expect, test, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
token: "work-order-vendor-e2e-token",
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
email: "admin@seahavenind.com",
|
||||||
|
userRoles: "Admin",
|
||||||
|
phoneNumber: "5551234567",
|
||||||
|
fullname: "Work Order E2E Admin",
|
||||||
|
id: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("edits only the vendor assigned to the current work order", async ({ page }) => {
|
||||||
|
let updateRequest: { url: string; body: Record<string, unknown> } | undefined;
|
||||||
|
|
||||||
|
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||||
|
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
Id: 501,
|
||||||
|
InternalWONumber: "WO-501",
|
||||||
|
WorkerOrderNumber: "CUSTOMER-501",
|
||||||
|
WorkerOrderTitle: "Emergency boiler repair",
|
||||||
|
Status: "Dispatched",
|
||||||
|
Comments: [],
|
||||||
|
AuditLog: [],
|
||||||
|
Dispatches: [
|
||||||
|
{
|
||||||
|
Id: 801,
|
||||||
|
DispatchNumber: "D-801",
|
||||||
|
VendorId: 7,
|
||||||
|
VendorName: "Gateway Plumbing",
|
||||||
|
VendorEmail: "dispatch@gateway.test",
|
||||||
|
Status: "Sent",
|
||||||
|
DispatchedAt: "2026-07-23T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/vendors/7/work-order-update", async (route) => {
|
||||||
|
updateRequest = {
|
||||||
|
url: route.request().url(),
|
||||||
|
body: route.request().postDataJSON() as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
await fulfillJson(route, { data: { id: 7 } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/vendors/7", (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
data: {
|
||||||
|
Id: 7,
|
||||||
|
CompanyName: "Gateway Plumbing",
|
||||||
|
ContactName: "Adam Whyte",
|
||||||
|
PreferredContact: "Email",
|
||||||
|
Phone: "3145550198",
|
||||||
|
Email: "dispatch@gateway.test",
|
||||||
|
Notes: "Preferred after-hours vendor",
|
||||||
|
IsActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/User*", (route) => fulfillJson(route, { data: [] }));
|
||||||
|
|
||||||
|
await page.goto("/workorders/501");
|
||||||
|
await expect(page.getByRole("heading", { name: "Emergency boiler repair" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Edit vendor" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Technician name")).toHaveValue("Adam Whyte");
|
||||||
|
await page.getByLabel("Technician name").fill("Taylor Tech");
|
||||||
|
await page.getByLabel("Preferred contact").click();
|
||||||
|
await page.getByRole("option", { name: "Text" }).click();
|
||||||
|
await page.getByLabel("Phone").fill("3145550111");
|
||||||
|
await page.getByLabel("Email").fill("taylor@example.com");
|
||||||
|
await page.getByLabel("Notes").fill("Text before arrival");
|
||||||
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "Edit assigned vendor" })).toBeHidden();
|
||||||
|
expect(updateRequest?.url).toMatch(/\/api\/vendors\/7\/work-order-update$/);
|
||||||
|
expect(updateRequest?.body).toEqual({
|
||||||
|
workOrderId: 501,
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
preferredContact: "Text",
|
||||||
|
phone: "(314) 555-0111",
|
||||||
|
email: "taylor@example.com",
|
||||||
|
notes: "Text before arrival",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -110,6 +110,7 @@ export const API_PATHS = {
|
||||||
delete: "Vendor/Delete",
|
delete: "Vendor/Delete",
|
||||||
dropdown: "Vendor/Dropdown",
|
dropdown: "Vendor/Dropdown",
|
||||||
facets: "Vendor/facets",
|
facets: "Vendor/facets",
|
||||||
|
deactivationImpact: "deactivation-impact",
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
getEventsForCalendar: "Calendar/GetEventsForCalendar",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
|
@ -40,6 +40,8 @@ const defaultValues: VendorFormValues = {
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateModalProps) {
|
||||||
|
|
@ -52,11 +54,15 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -67,13 +73,16 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
setValue("companyPhone", company.companyPhone);
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
setValue("email", company.email);
|
setValue("companyId", company.companyId, options);
|
||||||
setValue("address", company.address);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
setValue("city", company.city);
|
setValue("email", company.email, options);
|
||||||
setValue("state", company.state);
|
setValue("address", company.address, options);
|
||||||
setValue("zip", company.zip);
|
setValue("city", company.city, options);
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (values: VendorFormValues) => {
|
const onSubmit = (values: VendorFormValues) => {
|
||||||
|
|
@ -136,7 +145,7 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
disabled={createVendor.isPending}
|
disabled={createVendor.isPending || !isFormValid}
|
||||||
>
|
>
|
||||||
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
{createVendor.isPending ? "Saving..." : "Add Vendor"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, useWatch, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
|
@ -24,10 +24,16 @@ import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/ve
|
||||||
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
import { useUpdateVendor } from "@/domain/vendors/use-cases/use-update-vendor";
|
||||||
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
import { useVendorDetail } from "@/domain/vendors/use-cases/use-vendor-detail";
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import type { Vendor, VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { Vendor, VendorListItem, VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 420;
|
const DRAWER_WIDTH = 420;
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text Message",
|
||||||
|
};
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length === 0) return "—";
|
if (parts.length === 0) return "—";
|
||||||
|
|
@ -59,6 +65,8 @@ function toFormValues(vendor: Vendor): VendorFormValues {
|
||||||
zip: vendor.zip ?? "",
|
zip: vendor.zip ?? "",
|
||||||
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
tradeSpecialties: vendor.tradeSpecialties ?? "",
|
||||||
isActive: vendor.isActive,
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,10 +106,11 @@ export function VendorDetailDrawer({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
mode: "onTouched",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
contactName: "",
|
contactName: "",
|
||||||
|
|
@ -117,8 +126,12 @@ export function VendorDetailDrawer({
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && detail) {
|
if (open && detail) {
|
||||||
|
|
@ -135,13 +148,16 @@ export function VendorDetailDrawer({
|
||||||
|
|
||||||
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
const onCompanySelected = (company: (typeof companies)[number] | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
setValue("companyPhone", company.companyPhone);
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
setValue("email", company.email);
|
setValue("companyId", company.companyId, options);
|
||||||
setValue("address", company.address);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
setValue("city", company.city);
|
setValue("email", company.email, options);
|
||||||
setValue("state", company.state);
|
setValue("address", company.address, options);
|
||||||
setValue("zip", company.zip);
|
setValue("city", company.city, options);
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
|
@ -268,6 +284,10 @@ export function VendorDetailDrawer({
|
||||||
<DetailSection title="Technician">
|
<DetailSection title="Technician">
|
||||||
<DetailField label="Technician Name" value={detail.contactName} />
|
<DetailField label="Technician Name" value={detail.contactName} />
|
||||||
<DetailField label="Technician Phone" value={detail.phone} />
|
<DetailField label="Technician Phone" value={detail.phone} />
|
||||||
|
<DetailField
|
||||||
|
label="Preferred Contact"
|
||||||
|
value={PREFERRED_CONTACT_LABELS[detail.preferredContact ?? "Phone"]}
|
||||||
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="Company">
|
<DetailSection title="Company">
|
||||||
|
|
@ -417,7 +437,7 @@ export function VendorDetailDrawer({
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
form="vendor-edit-form"
|
form="vendor-edit-form"
|
||||||
disabled={updateVendor.isPending}
|
disabled={updateVendor.isPending || !isFormValid}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{updateVendor.isPending ? "Saving..." : "Save"}
|
{updateVendor.isPending ? "Saving..." : "Save"}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,15 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { VendorFacetCompany } from "@/domain/vendors/types/vendor";
|
import {
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||||
|
type VendorFacetCompany,
|
||||||
|
type VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
||||||
interface VendorFormFieldsProps {
|
interface VendorFormFieldsProps {
|
||||||
|
|
@ -38,6 +44,20 @@ function joinTrades(trades: string[]): string {
|
||||||
return trades.join(", ");
|
return trades.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPhoneMask(input: string): string {
|
||||||
|
const digits = (input ?? "").replace(/\D+/g, "").slice(0, 10);
|
||||||
|
if (digits.length === 0) return "";
|
||||||
|
if (digits.length < 4) return `(${digits}`;
|
||||||
|
if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_LABELS: Record<VendorPreferredContact, string> = {
|
||||||
|
Phone: "Phone",
|
||||||
|
Email: "Email",
|
||||||
|
Text: "Text",
|
||||||
|
};
|
||||||
|
|
||||||
export function VendorFormFields({
|
export function VendorFormFields({
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
|
|
@ -72,8 +92,46 @@ export function VendorFormFields({
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="phone"
|
name="phone"
|
||||||
render={({ field }) => <TextField {...field} label="Technician Phone" fullWidth />}
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Technician Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||||
|
error={Boolean(errors.phone)}
|
||||||
|
helperText={errors.phone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Box>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 0.5 }}>
|
||||||
|
Preferred Contact
|
||||||
|
</FormLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="preferredContact"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={field.value ?? "Phone"}
|
||||||
|
onChange={(_event, value: VendorPreferredContact | null) => {
|
||||||
|
if (value) field.onChange(value);
|
||||||
|
}}
|
||||||
|
aria-label="Preferred contact"
|
||||||
|
>
|
||||||
|
{VENDOR_PREFERRED_CONTACT_VALUES.map((option) => (
|
||||||
|
<ToggleButton key={option} value={option} aria-label={option}>
|
||||||
|
{PREFERRED_CONTACT_LABELS[option]}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -88,44 +146,54 @@ export function VendorFormFields({
|
||||||
control={control}
|
control={control}
|
||||||
name="companyName"
|
name="companyName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Controller
|
||||||
freeSolo
|
control={control}
|
||||||
options={companies}
|
name="companyId"
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
render={({ field: companyIdField }) => (
|
||||||
isOptionEqualToValue={(option, value) =>
|
<Autocomplete
|
||||||
typeof option === "string" || typeof value === "string"
|
freeSolo
|
||||||
? option === value
|
options={companies}
|
||||||
: option.name === value.name
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
}
|
isOptionEqualToValue={(option, value) =>
|
||||||
value={field.value ?? ""}
|
typeof option === "string" || typeof value === "string"
|
||||||
inputValue={field.value ?? ""}
|
? option === value
|
||||||
onInputChange={(_event, value, reason) => {
|
: option.name === value.name
|
||||||
if (reason === "input") {
|
|
||||||
field.onChange(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(_event, value, reason) => {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
field.onChange(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value) {
|
|
||||||
field.onChange(value.name);
|
|
||||||
if (reason === "selectOption") {
|
|
||||||
onCompanySelected(value);
|
|
||||||
}
|
}
|
||||||
} else {
|
value={field.value ?? ""}
|
||||||
field.onChange("");
|
inputValue={field.value ?? ""}
|
||||||
}
|
onInputChange={(_event, value, reason) => {
|
||||||
}}
|
if (reason === "input") {
|
||||||
renderInput={(params) => (
|
field.onChange(value);
|
||||||
<TextField
|
companyIdField.onChange(null);
|
||||||
{...params}
|
}
|
||||||
label="Company"
|
}}
|
||||||
required
|
onChange={(_event, value, reason) => {
|
||||||
placeholder="Select or type a company"
|
if (typeof value === "string") {
|
||||||
error={Boolean(errors.companyName)}
|
field.onChange(value);
|
||||||
helperText={errors.companyName?.message}
|
companyIdField.onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
field.onChange(value.name);
|
||||||
|
companyIdField.onChange(value.companyId);
|
||||||
|
if (reason === "selectOption") {
|
||||||
|
onCompanySelected(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.onChange("");
|
||||||
|
companyIdField.onChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Company"
|
||||||
|
required
|
||||||
|
placeholder="Select or type a company"
|
||||||
|
error={Boolean(errors.companyName)}
|
||||||
|
helperText={errors.companyName?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -134,7 +202,19 @@ export function VendorFormFields({
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="companyPhone"
|
name="companyPhone"
|
||||||
render={({ field }) => <TextField {...field} label="Company Phone" fullWidth />}
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Company Phone"
|
||||||
|
placeholder="(XXX) XXX-XXXX"
|
||||||
|
inputMode="tel"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(event) => field.onChange(formatPhoneMask(event.target.value))}
|
||||||
|
error={Boolean(errors.companyPhone)}
|
||||||
|
helperText={errors.companyPhone?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
|
|
@ -38,6 +38,8 @@ const defaultValues: VendorFormValues = {
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VendorFormPage() {
|
export default function VendorFormPage() {
|
||||||
|
|
@ -56,11 +58,15 @@ export default function VendorFormPage() {
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
control,
|
control,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VendorFormValues>({
|
} = useForm<VendorFormValues>({
|
||||||
resolver: zodResolver(vendorSchema),
|
resolver: zodResolver(vendorSchema),
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
const currentValues = useWatch({ control });
|
||||||
|
const isFormValid = vendorSchema.safeParse(currentValues).success;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (vendor && isEdit) {
|
if (vendor && isEdit) {
|
||||||
|
|
@ -79,19 +85,24 @@ export default function VendorFormPage() {
|
||||||
zip: vendor.zip,
|
zip: vendor.zip,
|
||||||
tradeSpecialties: vendor.tradeSpecialties,
|
tradeSpecialties: vendor.tradeSpecialties,
|
||||||
isActive: vendor.isActive,
|
isActive: vendor.isActive,
|
||||||
|
companyId: vendor.companyId ?? null,
|
||||||
|
preferredContact: vendor.preferredContact ?? "Phone",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [vendor, isEdit, reset]);
|
}, [vendor, isEdit, reset]);
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
setValue("companyPhone", company.companyPhone);
|
const options = { shouldDirty: true, shouldValidate: true } as const;
|
||||||
setValue("email", company.email);
|
setValue("companyId", company.companyId, options);
|
||||||
setValue("address", company.address);
|
setValue("companyPhone", company.companyPhone, options);
|
||||||
setValue("city", company.city);
|
setValue("email", company.email, options);
|
||||||
setValue("state", company.state);
|
setValue("address", company.address, options);
|
||||||
setValue("zip", company.zip);
|
setValue("city", company.city, options);
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("state", company.state, options);
|
||||||
|
setValue("zip", company.zip, options);
|
||||||
|
setValue("googleMapsUrl", company.googleMapsUrl, options);
|
||||||
|
void trigger();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSaving = createVendor.isPending || updateVendor.isPending;
|
const isSaving = createVendor.isPending || updateVendor.isPending;
|
||||||
|
|
@ -158,7 +169,7 @@ export default function VendorFormPage() {
|
||||||
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
<Button variant="outlined" onClick={() => navigate("/vendors")} disabled={isSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
<Button type="submit" variant="contained" disabled={isSaving || !isFormValid}>
|
||||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
155
src/app/(protected)/vendors/index.tsx
vendored
155
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
|
|
@ -38,6 +40,7 @@ import { VendorFiltersDrawer } from "./_components/vendor-filters-drawer";
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
import { emptyVendorFilters, type VendorFilters } from "./_components/vendor-filters";
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
import { useDeleteVendor } from "@/domain/vendors/use-cases/use-delete-vendor";
|
||||||
|
import { useVendorDeactivationImpact } from "@/domain/vendors/use-cases/use-vendor-deactivation-impact";
|
||||||
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
|
||||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
|
@ -100,11 +103,17 @@ export default function VendorsListPage() {
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<VendorListItem | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
const [detailMode, setDetailMode] = useState<"view" | "edit">("view");
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteVendor = useDeleteVendor();
|
const deleteVendor = useDeleteVendor();
|
||||||
|
const {
|
||||||
|
data: deactivationImpact,
|
||||||
|
isLoading: isLoadingImpact,
|
||||||
|
error: impactError,
|
||||||
|
} = useVendorDeactivationImpact(deleteTarget?.id);
|
||||||
const { data: facets } = useVendorFacets(isActive);
|
const { data: facets } = useVendorFacets(isActive);
|
||||||
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
|
||||||
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
const tradeOptions = useMemo(() => trades.map((trade) => trade.value), [trades]);
|
||||||
|
|
@ -169,9 +178,39 @@ export default function VendorsListPage() {
|
||||||
setDetailMode("view");
|
setDetailMode("view");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeactivation = (row: VendorListItem) => {
|
||||||
|
setDeleteTarget(row);
|
||||||
|
setDeleteError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeactivation = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
deleteVendor.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
setDeleteError(null);
|
||||||
|
deleteVendor.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setDeleteError(error.message || "Failed to deactivate vendor");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAnd = (handler: () => void) => (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handler();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowKeyDown = (row: VendorListItem) => (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openDetail(row, "view");
|
||||||
};
|
};
|
||||||
|
|
||||||
const showLoadingRow = isLoading;
|
const showLoadingRow = isLoading;
|
||||||
|
|
@ -304,7 +343,16 @@ export default function VendorsListPage() {
|
||||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
||||||
return (
|
return (
|
||||||
<TableRow key={String(row.id)} hover>
|
<TableRow
|
||||||
|
key={String(row.id)}
|
||||||
|
hover
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Open vendor details for ${row.companyName}`}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
onClick={() => openDetail(row, "view")}
|
||||||
|
onKeyDown={handleRowKeyDown(row)}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -336,7 +384,11 @@ export default function VendorsListPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.phone ? (
|
{row.phone ? (
|
||||||
<Link href={`tel:${row.phone}`} underline="hover">
|
<Link
|
||||||
|
href={`tel:${row.phone}`}
|
||||||
|
underline="hover"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
{row.phone}
|
{row.phone}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -345,7 +397,11 @@ export default function VendorsListPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.email ? (
|
{row.email ? (
|
||||||
<Link href={`mailto:${row.email}`} underline="hover">
|
<Link
|
||||||
|
href={`mailto:${row.email}`}
|
||||||
|
underline="hover"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
{row.email}
|
{row.email}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -365,7 +421,7 @@ export default function VendorsListPage() {
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`View vendor ${row.companyName}`}
|
aria-label={`View vendor ${row.companyName}`}
|
||||||
onClick={() => openDetail(row, "view")}
|
onClick={stopAnd(() => openDetail(row, "view"))}
|
||||||
>
|
>
|
||||||
<VisibilityOutlinedIcon fontSize="small" />
|
<VisibilityOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -381,7 +437,7 @@ export default function VendorsListPage() {
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`Edit vendor ${row.companyName}`}
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
onClick={() => openDetail(row, "edit")}
|
onClick={stopAnd(() => openDetail(row, "edit"))}
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<EditOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -391,7 +447,7 @@ export default function VendorsListPage() {
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Deactivate vendor"
|
aria-label="Deactivate vendor"
|
||||||
onClick={() => setDeleteTarget(row)}
|
onClick={stopAnd(() => openDeactivation(row))}
|
||||||
>
|
>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -458,23 +514,92 @@ export default function VendorsListPage() {
|
||||||
tradeOptions={tradeOptions}
|
tradeOptions={tradeOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
<Dialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onClose={deleteVendor.isPending ? undefined : closeDeactivation}
|
||||||
|
>
|
||||||
<DialogTitle>Deactivate Vendor</DialogTitle>
|
<DialogTitle>Deactivate Vendor</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<Stack spacing={2}>
|
||||||
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
<DialogContentText>
|
||||||
history will be preserved.
|
Deactivate "{deleteTarget?.companyName}"? Existing work-order and audit
|
||||||
</DialogContentText>
|
history will be preserved.
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
{isLoadingImpact && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Checking linked work orders...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{impactError && !isLoadingImpact && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Could not verify linked work orders. Close and try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deactivationImpact && !isLoadingImpact && !deactivationImpact.canDeactivate && (
|
||||||
|
<Alert severity="error">
|
||||||
|
This vendor cannot be deactivated because it still has open work orders.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deactivationImpact && deactivationImpact.openWorkOrders.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Open work orders ({deactivationImpact.openWorkOrders.length})
|
||||||
|
</Typography>
|
||||||
|
<List dense sx={{ mt: 0.5 }}>
|
||||||
|
{deactivationImpact.openWorkOrders.map((wo) => (
|
||||||
|
<ListItem
|
||||||
|
key={String(wo.workOrderId)}
|
||||||
|
sx={{ px: 0, py: 0.25 }}
|
||||||
|
secondaryAction={
|
||||||
|
wo.scheduledDate ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.scheduledDate}
|
||||||
|
</Typography>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{wo.workOrderNumber
|
||||||
|
? `${wo.workOrderNumber} — ${wo.workOrderTitle || "Untitled"}`
|
||||||
|
: wo.workOrderTitle || `Work order ${wo.workOrderId}`}
|
||||||
|
</Typography>
|
||||||
|
{wo.status && (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{wo.status}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteError && <Alert severity="error">{deleteError}</Alert>}
|
||||||
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteVendor.isPending}>
|
<Button onClick={closeDeactivation} disabled={deleteVendor.isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="error"
|
color="error"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteVendor.isPending}
|
disabled={
|
||||||
|
deleteVendor.isPending ||
|
||||||
|
isLoadingImpact ||
|
||||||
|
Boolean(deactivationImpact && !deactivationImpact.canDeactivate) ||
|
||||||
|
Boolean(impactError)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
{deleteVendor.isPending ? "Deactivating..." : "Deactivate"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
Link,
|
Link,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
|
@ -36,8 +40,11 @@ import {
|
||||||
useAddWorkOrderComment,
|
useAddWorkOrderComment,
|
||||||
useChangeWorkOrderAssignment,
|
useChangeWorkOrderAssignment,
|
||||||
useChangeWorkOrderStatus,
|
useChangeWorkOrderStatus,
|
||||||
|
useUpdateWorkOrderVendor,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
|
||||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
const STATUS_COLORS: Record<
|
const STATUS_COLORS: Record<
|
||||||
|
|
@ -53,6 +60,31 @@ const STATUS_COLORS: Record<
|
||||||
|
|
||||||
type ActivityTab = "customer" | "internal" | "audit";
|
type ActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
|
interface VendorEditorState {
|
||||||
|
vendorId: string | number;
|
||||||
|
vendorName: string;
|
||||||
|
contactName: string;
|
||||||
|
preferredContact: VendorPreferredContact;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhoneMask(input: string): string {
|
||||||
|
const digits = input.replace(/\D/g, "").slice(0, 10);
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVendorEditorValid(editor: VendorEditorState): boolean {
|
||||||
|
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
|
||||||
|
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
|
||||||
|
return (
|
||||||
|
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
||||||
const sorted = useMemo(
|
const sorted = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -167,6 +199,7 @@ export default function WorkOrderViewPage() {
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||||
|
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||||
const [commentText, setCommentText] = useState("");
|
const [commentText, setCommentText] = useState("");
|
||||||
|
|
@ -174,6 +207,9 @@ export default function WorkOrderViewPage() {
|
||||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
|
||||||
|
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
|
||||||
|
const [vendorEditorError, setVendorEditorError] = useState("");
|
||||||
|
|
||||||
const customerComments = useMemo(
|
const customerComments = useMemo(
|
||||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||||
|
|
@ -200,6 +236,74 @@ export default function WorkOrderViewPage() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openVendorEditor = async (
|
||||||
|
event: React.MouseEvent,
|
||||||
|
vendorId: string | number,
|
||||||
|
vendorName: string,
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setVendorEditorError("");
|
||||||
|
setVendorEditorLoading(true);
|
||||||
|
setVendorEditor({
|
||||||
|
vendorId,
|
||||||
|
vendorName,
|
||||||
|
contactName: "",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vendor = await vendorsApi.getById(vendorId);
|
||||||
|
setVendorEditor({
|
||||||
|
vendorId,
|
||||||
|
vendorName: vendor.companyName || vendorName,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
preferredContact: vendor.preferredContact,
|
||||||
|
phone: vendor.phone,
|
||||||
|
email: vendor.email,
|
||||||
|
notes: vendor.notes,
|
||||||
|
});
|
||||||
|
} catch (vendorError) {
|
||||||
|
setVendorEditorError(
|
||||||
|
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setVendorEditorLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveVendorEditor = () => {
|
||||||
|
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
|
||||||
|
const numericWorkOrderId = Number(workOrderId);
|
||||||
|
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
|
||||||
|
setVendorEditorError("A valid work order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vendorMutation.mutate(
|
||||||
|
{
|
||||||
|
vendorId: vendorEditor.vendorId,
|
||||||
|
payload: {
|
||||||
|
workOrderId: numericWorkOrderId,
|
||||||
|
contactName: vendorEditor.contactName.trim(),
|
||||||
|
preferredContact: vendorEditor.preferredContact,
|
||||||
|
phone: vendorEditor.phone,
|
||||||
|
email: vendorEditor.email.trim(),
|
||||||
|
notes: vendorEditor.notes.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setVendorEditor(null);
|
||||||
|
void refetch();
|
||||||
|
},
|
||||||
|
onError: (vendorError) => setVendorEditorError(vendorError.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center p-12">
|
<Box className="flex justify-center p-12">
|
||||||
|
|
@ -426,6 +530,7 @@ export default function WorkOrderViewPage() {
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
<TableCell>Date</TableCell>
|
<TableCell>Date</TableCell>
|
||||||
<TableCell>Wait</TableCell>
|
<TableCell>Wait</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -465,6 +570,17 @@ export default function WorkOrderViewPage() {
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={(event) =>
|
||||||
|
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
|
||||||
|
}
|
||||||
|
disabled={dispatch.vendorId === ""}
|
||||||
|
>
|
||||||
|
Edit vendor
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -550,6 +666,115 @@ export default function WorkOrderViewPage() {
|
||||||
onUpdated={() => void refetch()}
|
onUpdated={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Dialog
|
||||||
|
open={vendorEditor !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!vendorMutation.isPending) setVendorEditor(null);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<DialogTitle>Edit assigned vendor</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} className="pt-2">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{vendorEditor?.vendorName}
|
||||||
|
</Typography>
|
||||||
|
{vendorEditorError && <Alert severity="error">{vendorEditorError}</Alert>}
|
||||||
|
{vendorEditorLoading ? (
|
||||||
|
<Box className="flex justify-center p-6">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Technician name"
|
||||||
|
value={vendorEditor?.contactName ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current ? { ...current, contactName: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 200 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Preferred contact"
|
||||||
|
value={vendorEditor?.preferredContact ?? "Phone"}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
preferredContact: event.target.value as VendorPreferredContact,
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="Phone">Phone</MenuItem>
|
||||||
|
<MenuItem value="Email">Email</MenuItem>
|
||||||
|
<MenuItem value="Text">Text</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Phone"
|
||||||
|
value={vendorEditor?.phone ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, phone: formatPhoneMask(event.target.value) }
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error={Boolean(
|
||||||
|
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
|
||||||
|
)}
|
||||||
|
helperText="Use (XXX) XXX-XXXX"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={vendorEditor?.email ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current ? { ...current, email: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Notes"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
value={vendorEditor?.notes ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorEditor((current) =>
|
||||||
|
current ? { ...current, notes: event.target.value } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setVendorEditor(null)} disabled={vendorMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={saveVendorEditor}
|
||||||
|
disabled={
|
||||||
|
!vendorEditor ||
|
||||||
|
vendorEditorLoading ||
|
||||||
|
vendorMutation.isPending ||
|
||||||
|
!isVendorEditorValid(vendorEditor)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{vendorMutation.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
src/domain/vendors/api/vendors-api.ts
vendored
9
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -3,6 +3,7 @@ import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
mapVendor,
|
mapVendor,
|
||||||
|
mapVendorDeactivationImpact,
|
||||||
mapVendorFacets,
|
mapVendorFacets,
|
||||||
mapVendorListItems,
|
mapVendorListItems,
|
||||||
mapVendorPortalToken,
|
mapVendorPortalToken,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
} from "@/domain/vendors/mappers/vendor-mapper";
|
} from "@/domain/vendors/mappers/vendor-mapper";
|
||||||
import type {
|
import type {
|
||||||
Vendor,
|
Vendor,
|
||||||
|
VendorDeactivationImpact,
|
||||||
VendorFacets,
|
VendorFacets,
|
||||||
VendorPortalToken,
|
VendorPortalToken,
|
||||||
VendorsListParams,
|
VendorsListParams,
|
||||||
|
|
@ -102,6 +104,13 @@ export const vendorsApi = {
|
||||||
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
|
await apiDelete(`${API_PATHS.rest.vendors}/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDeactivationImpact: async (id: string | number): Promise<VendorDeactivationImpact> => {
|
||||||
|
const data = await apiGet<unknown>(
|
||||||
|
`${API_PATHS.rest.vendors}/${id}/${API_PATHS.vendor.deactivationImpact}`,
|
||||||
|
);
|
||||||
|
return mapVendorDeactivationImpact(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
|
getPortalToken: async (id: string | number): Promise<VendorPortalToken | null> => {
|
||||||
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
|
const data = await apiGet<unknown>(`${API_PATHS.rest.vendors}/${id}/portal-token`);
|
||||||
return mapVendorPortalToken(handleApiResponse(data));
|
return mapVendorPortalToken(handleApiResponse(data));
|
||||||
|
|
|
||||||
113
src/domain/vendors/mappers/vendor-mapper.ts
vendored
113
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -1,13 +1,18 @@
|
||||||
import type {
|
import type {
|
||||||
Vendor,
|
Vendor,
|
||||||
|
VendorDeactivationImpact,
|
||||||
VendorFacetCompany,
|
VendorFacetCompany,
|
||||||
VendorFacetJobBucket,
|
VendorFacetJobBucket,
|
||||||
VendorFacetLocation,
|
VendorFacetLocation,
|
||||||
VendorFacets,
|
VendorFacets,
|
||||||
VendorListItem,
|
VendorListItem,
|
||||||
|
VendorOpenWorkOrderSummary,
|
||||||
VendorPortalToken,
|
VendorPortalToken,
|
||||||
|
VendorPreferredContact,
|
||||||
} from "@/domain/vendors/types/vendor";
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
|
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
|
||||||
|
|
||||||
function asRecord(raw: unknown): Record<string, unknown> {
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +44,51 @@ function readId(record: Record<string, unknown>): string | number {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCompanyId(record: Record<string, unknown>): string | number | null {
|
||||||
|
const value = record.companyId ?? record.CompanyId;
|
||||||
|
if (typeof value === "string") return value.trim() === "" ? null : value;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumericCompanyId(record: Record<string, unknown>): number | null {
|
||||||
|
const value = readCompanyId(record);
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (typeof value !== "string" || value.trim() === "") return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreferredContact(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
...keys: string[]
|
||||||
|
): VendorPreferredContact {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) {
|
||||||
|
return normalized as VendorPreferredContact;
|
||||||
|
}
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
if (lowered === "phone" || lowered === "call") return "Phone";
|
||||||
|
if (lowered === "email" || lowered === "e-mail") return "Email";
|
||||||
|
if (lowered === "text" || lowered === "sms") return "Text";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Phone";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCanonicalPhone(value: unknown): string {
|
||||||
|
const original = String(value ?? "");
|
||||||
|
let digits = original.replace(/\D+/g, "");
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
digits = digits.slice(1);
|
||||||
|
}
|
||||||
|
if (digits.length !== 10) return original;
|
||||||
|
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
|
|
@ -51,6 +101,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
companyId: readCompanyId(item),
|
||||||
companyName: readString(item, "companyName", "CompanyName"),
|
companyName: readString(item, "companyName", "CompanyName"),
|
||||||
contactName: readString(item, "contactName", "ContactName"),
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
email: readString(item, "email", "Email"),
|
email: readString(item, "email", "Email"),
|
||||||
|
|
@ -63,6 +114,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||||
state: readString(item, "state", "State"),
|
state: readString(item, "state", "State"),
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
isActive: readBool(item, "isActive", "IsActive"),
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +126,7 @@ export function mapVendor(raw: unknown): Vendor {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
id: readId(item) || undefined,
|
id: readId(item) || undefined,
|
||||||
|
companyId: readCompanyId(item),
|
||||||
companyName: readString(item, "companyName", "CompanyName"),
|
companyName: readString(item, "companyName", "CompanyName"),
|
||||||
contactName: readString(item, "contactName", "ContactName"),
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
email: readString(item, "email", "Email"),
|
email: readString(item, "email", "Email"),
|
||||||
|
|
@ -88,25 +141,29 @@ export function mapVendor(raw: unknown): Vendor {
|
||||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
isActive: readBool(item, "isActive", "IsActive"),
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
const item = asRecord(vendor);
|
const item = asRecord(vendor);
|
||||||
|
const companyName = readString(item, "companyName", "CompanyName");
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
companyName: item.companyName ?? "",
|
companyId: readNumericCompanyId(item),
|
||||||
contactName: item.contactName ?? "",
|
companyName,
|
||||||
email: item.email ?? "",
|
contactName: readString(item, "contactName", "ContactName"),
|
||||||
phone: item.phone ?? "",
|
email: readString(item, "email", "Email"),
|
||||||
companyPhone: item.companyPhone ?? "",
|
phone: toCanonicalPhone(item.phone ?? item.Phone),
|
||||||
googleMapsUrl: item.googleMapsUrl ?? "",
|
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
|
||||||
notes: item.notes ?? "",
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
address: item.address ?? "",
|
notes: readString(item, "notes", "Notes"),
|
||||||
city: item.city ?? "",
|
address: readString(item, "address", "Address"),
|
||||||
state: item.state ?? "",
|
city: readString(item, "city", "City"),
|
||||||
zip: item.zip ?? "",
|
state: readString(item, "state", "State"),
|
||||||
tradeSpecialties: item.tradeSpecialties ?? "",
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
isActive: item.isActive ?? true,
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
|
isActive: readBool(item, "isActive", "IsActive"),
|
||||||
|
preferredContact: readPreferredContact(item, "preferredContact", "PreferredContact"),
|
||||||
};
|
};
|
||||||
const id = item.id ?? item.Id;
|
const id = item.id ?? item.Id;
|
||||||
if (typeof id === "string" || typeof id === "number") {
|
if (typeof id === "string" || typeof id === "number") {
|
||||||
|
|
@ -118,6 +175,7 @@ export function mapVendorToBackend(vendor: unknown): Record<string, unknown> {
|
||||||
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
function mapFacetCompany(raw: unknown): VendorFacetCompany {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
return {
|
return {
|
||||||
|
companyId: readCompanyId(item),
|
||||||
name: readString(item, "name", "Name"),
|
name: readString(item, "name", "Name"),
|
||||||
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
|
||||||
email: readString(item, "email", "Email"),
|
email: readString(item, "email", "Email"),
|
||||||
|
|
@ -182,3 +240,32 @@ export function mapVendorPortalToken(raw: unknown): VendorPortalToken | null {
|
||||||
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
|
lastUsedAt: readString(item, "lastUsedAt", "LastUsedAt") || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapOpenWorkOrder(raw: unknown): VendorOpenWorkOrderSummary {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const workOrderId = item.workOrderId ?? item.WorkOrderId;
|
||||||
|
const dispatchId = item.dispatchId ?? item.DispatchId;
|
||||||
|
return {
|
||||||
|
workOrderId:
|
||||||
|
typeof workOrderId === "string" || typeof workOrderId === "number" ? workOrderId : "",
|
||||||
|
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
|
||||||
|
workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle"),
|
||||||
|
status: readString(item, "status", "Status"),
|
||||||
|
scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined,
|
||||||
|
dispatchId:
|
||||||
|
typeof dispatchId === "string" || typeof dispatchId === "number" ? dispatchId : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapVendorDeactivationImpact(raw: unknown): VendorDeactivationImpact {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const openWorkOrdersRaw = item.openWorkOrders ?? item.OpenWorkOrders;
|
||||||
|
const rawId = item.vendorId ?? item.VendorId ?? item.id ?? item.Id;
|
||||||
|
const vendorId: string | number =
|
||||||
|
typeof rawId === "string" || typeof rawId === "number" ? rawId : "";
|
||||||
|
return {
|
||||||
|
vendorId,
|
||||||
|
canDeactivate: readBool(item, "canDeactivate", "CanDeactivate"),
|
||||||
|
openWorkOrders: Array.isArray(openWorkOrdersRaw) ? openWorkOrdersRaw.map(mapOpenWorkOrder) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
18
src/domain/vendors/schemas/vendor-schema.ts
vendored
18
src/domain/vendors/schemas/vendor-schema.ts
vendored
|
|
@ -1,4 +1,8 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
VENDOR_PREFERRED_CONTACT_VALUES,
|
||||||
|
type VendorPreferredContact,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const httpsUrl = z
|
const httpsUrl = z
|
||||||
.string()
|
.string()
|
||||||
|
|
@ -8,12 +12,18 @@ const httpsUrl = z
|
||||||
"Google Maps URL must use HTTPS",
|
"Google Maps URL must use HTTPS",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const northAmericanPhone = z.string().refine((value) => {
|
||||||
|
if (value === "") return true;
|
||||||
|
const digits = value.replace(/\D+/g, "");
|
||||||
|
return digits.length === 10 || (digits.length === 11 && digits.startsWith("1"));
|
||||||
|
}, "Enter a 10-digit phone number");
|
||||||
|
|
||||||
export const vendorSchema = z.object({
|
export const vendorSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company is required"),
|
companyName: z.string().min(1, "Company is required"),
|
||||||
contactName: z.string().min(1, "Technician name is required"),
|
contactName: z.string().min(1, "Technician name is required"),
|
||||||
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
|
email: z.union([z.string().email("Invalid email"), z.literal("")]).optional(),
|
||||||
phone: z.string().optional(),
|
phone: northAmericanPhone.optional(),
|
||||||
companyPhone: z.string().optional(),
|
companyPhone: northAmericanPhone.optional(),
|
||||||
googleMapsUrl: httpsUrl.optional(),
|
googleMapsUrl: httpsUrl.optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
totalJobs: z.number().optional(),
|
totalJobs: z.number().optional(),
|
||||||
|
|
@ -23,6 +33,10 @@ export const vendorSchema = z.object({
|
||||||
zip: z.string().optional(),
|
zip: z.string().optional(),
|
||||||
tradeSpecialties: z.string().optional(),
|
tradeSpecialties: z.string().optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
|
companyId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
|
preferredContact: z
|
||||||
|
.enum(VENDOR_PREFERRED_CONTACT_VALUES as [VendorPreferredContact, ...VendorPreferredContact[]])
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
export type VendorFormValues = z.infer<typeof vendorSchema>;
|
||||||
|
|
|
||||||
24
src/domain/vendors/types/vendor.ts
vendored
24
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -1,5 +1,10 @@
|
||||||
|
export type VendorPreferredContact = "Phone" | "Email" | "Text";
|
||||||
|
|
||||||
|
export const VENDOR_PREFERRED_CONTACT_VALUES: VendorPreferredContact[] = ["Phone", "Email", "Text"];
|
||||||
|
|
||||||
export interface VendorListItem {
|
export interface VendorListItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
companyId: string | number | null;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -12,10 +17,12 @@ export interface VendorListItem {
|
||||||
state: string;
|
state: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
preferredContact: VendorPreferredContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Vendor {
|
export interface Vendor {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
|
companyId: string | number | null;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -30,6 +37,7 @@ export interface Vendor {
|
||||||
zip: string;
|
zip: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
preferredContact: VendorPreferredContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorsListParams {
|
export interface VendorsListParams {
|
||||||
|
|
@ -51,6 +59,7 @@ export interface VendorsListResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorFacetCompany {
|
export interface VendorFacetCompany {
|
||||||
|
companyId: string | number | null;
|
||||||
name: string;
|
name: string;
|
||||||
companyPhone: string;
|
companyPhone: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -85,3 +94,18 @@ export interface VendorPortalToken {
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VendorOpenWorkOrderSummary {
|
||||||
|
workOrderId: string | number;
|
||||||
|
workOrderNumber: string;
|
||||||
|
workOrderTitle: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
dispatchId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorDeactivationImpact {
|
||||||
|
vendorId: string | number;
|
||||||
|
canDeactivate: boolean;
|
||||||
|
openWorkOrders: VendorOpenWorkOrderSummary[];
|
||||||
|
}
|
||||||
|
|
|
||||||
22
src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts
vendored
Normal file
22
src/domain/vendors/use-cases/use-vendor-deactivation-impact.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
import type { VendorDeactivationImpact } from "@/domain/vendors/types/vendor";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
export function useVendorDeactivationImpact(
|
||||||
|
id: string | number | undefined,
|
||||||
|
): UseQueryResult<VendorDeactivationImpact, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.vendors.deactivationImpact(id ?? ""),
|
||||||
|
queryFn: () => vendorsApi.getDeactivationImpact(requireParam(id)),
|
||||||
|
enabled: id !== undefined && id !== null && id !== "",
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireParam(id: string | number | undefined): string | number {
|
||||||
|
if (id === undefined || id === null || id === "") {
|
||||||
|
throw new Error("Vendor id is required");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { apiDelete, apiGet, apiPost } from "@/api/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
isWoTableMockEnabled,
|
isWoTableMockEnabled,
|
||||||
|
|
@ -37,6 +37,7 @@ import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
TaskListTemplate,
|
TaskListTemplate,
|
||||||
UpdateDispatchPayload,
|
UpdateDispatchPayload,
|
||||||
|
WorkOrderVendorUpdatePayload,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderComment,
|
WorkOrderComment,
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
|
|
@ -294,6 +295,13 @@ export const workOrdersApi = {
|
||||||
await apiPost<unknown>(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
|
await apiPost<unknown>(API_PATHS.workOrder.updateDispatch, mapUpdateDispatchPayload(payload));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateVendorFromWorkOrder: async (
|
||||||
|
vendorId: string | number,
|
||||||
|
payload: WorkOrderVendorUpdatePayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiPut<unknown>(`${API_PATHS.rest.vendors}/${vendorId}/work-order-update`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
addDispatchComment: async (
|
addDispatchComment: async (
|
||||||
dispatchId: string | number,
|
dispatchId: string | number,
|
||||||
text: string,
|
text: string,
|
||||||
|
|
|
||||||
|
|
@ -250,9 +250,11 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
vendorId: readId({ id: item.vendorId ?? item.VendorId }),
|
||||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||||
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
||||||
vendorName: readString(item, "vendorName", "VendorName"),
|
vendorName: readString(item, "vendorName", "VendorName"),
|
||||||
|
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
|
||||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||||
status: readString(item, "status", "Status"),
|
status: readString(item, "status", "Status"),
|
||||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
export interface WorkOrderDispatchSummary {
|
export interface WorkOrderDispatchSummary {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
vendorId: string | number;
|
||||||
dispatchNumber: string;
|
dispatchNumber: string;
|
||||||
poNumber: string;
|
poNumber: string;
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
|
vendorEmail: string;
|
||||||
nteAmount: number | null;
|
nteAmount: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
dispatchedAt: string;
|
dispatchedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderVendorUpdatePayload {
|
||||||
|
workOrderId: number;
|
||||||
|
contactName?: string;
|
||||||
|
preferredContact: "Phone" | "Email" | "Text";
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DispatchChecklistItem {
|
export interface DispatchChecklistItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
itemText: string;
|
itemText: string;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export type {
|
||||||
DispatchWorkOrderRef,
|
DispatchWorkOrderRef,
|
||||||
UpdateDispatchPayload,
|
UpdateDispatchPayload,
|
||||||
WorkOrderDispatchSummary,
|
WorkOrderDispatchSummary,
|
||||||
|
WorkOrderVendorUpdatePayload,
|
||||||
} from "./dispatch";
|
} from "./dispatch";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
WorkOrderComment,
|
WorkOrderComment,
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
WorkOrderFormValues,
|
WorkOrderFormValues,
|
||||||
|
WorkOrderVendorUpdatePayload,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
|
@ -46,6 +47,11 @@ interface ChangeWorkOrderAssignmentInput {
|
||||||
userName: string;
|
userName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdateWorkOrderVendorInput {
|
||||||
|
vendorId: string | number;
|
||||||
|
payload: WorkOrderVendorUpdatePayload;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateWorkOrder(): UseMutationResult<
|
export function useCreateWorkOrder(): UseMutationResult<
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
Error,
|
Error,
|
||||||
|
|
@ -89,6 +95,26 @@ export function useUpdateWorkOrder(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateWorkOrderVendor(
|
||||||
|
workOrderId: string | number,
|
||||||
|
): UseMutationResult<void, Error, UpdateWorkOrderVendorInput> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ vendorId, payload }) =>
|
||||||
|
workOrdersApi.updateVendorFromWorkOrder(vendorId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
|
||||||
|
toast.success("Vendor contact updated");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to update vendor contact");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAddWorkOrderComment(
|
export function useAddWorkOrderComment(
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {
|
): UseMutationResult<WorkOrderComment, Error, AddWorkOrderCommentInput> {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ export const queryKeys = {
|
||||||
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
|
facets: (isActive?: boolean) => [...queryKeys.vendors.all, "facets", isActive ?? null] as const,
|
||||||
portalToken: (vendorId: string | number) =>
|
portalToken: (vendorId: string | number) =>
|
||||||
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
[...queryKeys.vendors.all, "portalToken", vendorId] as const,
|
||||||
|
deactivationImpact: (vendorId: string | number) =>
|
||||||
|
[...queryKeys.vendors.all, "deactivationImpact", vendorId] as const,
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
all: ["calendar"] as const,
|
all: ["calendar"] as const,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm, type FieldErrors } from "react-hook-form";
|
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
|
import { VendorFormFields } from "@/app/(protected)/vendors/_components/vendor-form-fields";
|
||||||
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
import { vendorSchema, type VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||||
|
|
@ -10,6 +10,7 @@ import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const companies: VendorFacetCompany[] = [
|
const companies: VendorFacetCompany[] = [
|
||||||
{
|
{
|
||||||
|
companyId: "co-1",
|
||||||
name: "Gateway Plumbing",
|
name: "Gateway Plumbing",
|
||||||
companyPhone: "(314) 555-0198",
|
companyPhone: "(314) 555-0198",
|
||||||
email: "dispatch@gatewayplumbing.com",
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
|
@ -36,6 +37,8 @@ const emptyForm: VendorFormValues = {
|
||||||
zip: "",
|
zip: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
companyId: null,
|
||||||
|
preferredContact: "Phone",
|
||||||
};
|
};
|
||||||
|
|
||||||
function Harness({
|
function Harness({
|
||||||
|
|
@ -52,6 +55,7 @@ function Harness({
|
||||||
|
|
||||||
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
const onCompanySelected = (company: VendorFacetCompany | null) => {
|
||||||
if (!company) return;
|
if (!company) return;
|
||||||
|
setValue("companyId", company.companyId);
|
||||||
setValue("companyPhone", company.companyPhone);
|
setValue("companyPhone", company.companyPhone);
|
||||||
setValue("email", company.email);
|
setValue("email", company.email);
|
||||||
setValue("address", company.address);
|
setValue("address", company.address);
|
||||||
|
|
@ -61,6 +65,8 @@ function Harness({
|
||||||
setValue("googleMapsUrl", company.googleMapsUrl);
|
setValue("googleMapsUrl", company.googleMapsUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const companyId = useWatch({ control, name: "companyId" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VendorFormFields
|
<VendorFormFields
|
||||||
|
|
@ -70,6 +76,7 @@ function Harness({
|
||||||
tradeOptions={[]}
|
tradeOptions={[]}
|
||||||
onCompanySelected={onCompanySelected}
|
onCompanySelected={onCompanySelected}
|
||||||
/>
|
/>
|
||||||
|
<output data-testid="company-id">{String(companyId ?? "")}</output>
|
||||||
<button type="button" onClick={() => reset(resetTarget)}>
|
<button type="button" onClick={() => reset(resetTarget)}>
|
||||||
reset-form
|
reset-form
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -78,6 +85,7 @@ function Harness({
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
|
const companyField = () => screen.getByRole("combobox", { name: "Company" }) as HTMLInputElement;
|
||||||
|
const companyIdOutput = () => screen.getByTestId("company-id");
|
||||||
|
|
||||||
describe("VendorFormFields company autocomplete state", () => {
|
describe("VendorFormFields company autocomplete state", () => {
|
||||||
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
|
it("clears a previously typed company when the form is reset (create-form reopen)", async () => {
|
||||||
|
|
@ -148,4 +156,50 @@ describe("VendorFormFields company autocomplete state", () => {
|
||||||
expect(companyField()).toHaveValue("Gateway Plumbing");
|
expect(companyField()).toHaveValue("Gateway Plumbing");
|
||||||
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
|
expect(screen.getByRole("textbox", { name: "Company Phone" })).toHaveValue("(314) 555-0198");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets companyId when a known company is selected and clears it when the text changes", async () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(companyField(), "Gate");
|
||||||
|
await userEvent.click(await screen.findByRole("option", { name: "Gateway Plumbing" }));
|
||||||
|
expect(companyIdOutput()).toHaveTextContent("co-1");
|
||||||
|
|
||||||
|
await userEvent.type(companyField(), "!");
|
||||||
|
expect(companyIdOutput()).toHaveTextContent("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps companyId null for free-typed company text that matches no option", async () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(companyField(), "Independent Vendor LLC");
|
||||||
|
expect(companyIdOutput()).toHaveTextContent("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats technician and company phones into the (XXX) XXX-XXXX mask", async () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const phone = screen.getByRole("textbox", { name: "Technician Phone" });
|
||||||
|
await userEvent.type(phone, "3145550198");
|
||||||
|
expect(phone).toHaveValue("(314) 555-0198");
|
||||||
|
|
||||||
|
const companyPhone = screen.getByRole("textbox", { name: "Company Phone" });
|
||||||
|
await userEvent.type(companyPhone, "3145550100");
|
||||||
|
expect(companyPhone).toHaveValue("(314) 555-0100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a preferred contact selector with Phone, Email, and Text options", () => {
|
||||||
|
renderWithProviders(<Harness defaultValues={emptyForm} resetTarget={emptyForm} />, {
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Phone" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.getByRole("button", { name: "Email" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,22 @@ import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const useVendorsList = vi.fn();
|
const useVendorsList = vi.fn();
|
||||||
|
const useVendorDetail = vi.fn();
|
||||||
|
const useVendorDeactivationImpact = vi.fn();
|
||||||
const mutate = vi.fn();
|
const mutate = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-vendors-list", () => ({
|
||||||
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-detail", () => ({
|
||||||
|
useVendorDetail: (...args: unknown[]) => useVendorDetail(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-vendor-deactivation-impact", () => ({
|
||||||
|
useVendorDeactivationImpact: (...args: unknown[]) => useVendorDeactivationImpact(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
|
vi.mock("@/domain/vendors/use-cases/use-delete-vendor", () => ({
|
||||||
useDeleteVendor: () => ({ mutate, isPending: false }),
|
useDeleteVendor: () => ({ mutate, isPending: false }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -21,6 +31,7 @@ vi.mock("@/hooks/use-debounce", () => ({
|
||||||
|
|
||||||
const activeVendor = {
|
const activeVendor = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
companyId: "co-1",
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
email: "dispatch@gatewayplumbing.com",
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
|
@ -29,6 +40,7 @@ const activeVendor = {
|
||||||
state: "MO",
|
state: "MO",
|
||||||
tradeSpecialties: "Backflow Preventers",
|
tradeSpecialties: "Backflow Preventers",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
preferredContact: "Email",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inactiveVendor = {
|
const inactiveVendor = {
|
||||||
|
|
@ -47,8 +59,18 @@ function result(items: (typeof activeVendor)[], totalCount: number) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupDefaults() {
|
||||||
|
useVendorDetail.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||||
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("VendorsListPage", () => {
|
describe("VendorsListPage", () => {
|
||||||
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||||
|
setupDefaults();
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
if (params.pageSize === 1) return result([], params.isActive ? 1 : 1);
|
||||||
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
|
return params.isActive ? result([activeVendor], 1) : result([inactiveVendor], 1);
|
||||||
|
|
@ -67,6 +89,7 @@ describe("VendorsListPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
||||||
|
setupDefaults();
|
||||||
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
||||||
...activeVendor,
|
...activeVendor,
|
||||||
id: 100 + index,
|
id: 100 + index,
|
||||||
|
|
@ -83,4 +106,58 @@ describe("VendorsListPage", () => {
|
||||||
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
useVendorDetail.mockReturnValue({ data: activeVendor, isLoading: false, error: null });
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
const row = screen.getByRole("button", {
|
||||||
|
name: "Open vendor details for Gateway Plumbing",
|
||||||
|
});
|
||||||
|
row.focus();
|
||||||
|
await userEvent.keyboard("{Enter}");
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks deactivation when the preflight reports open work orders", async () => {
|
||||||
|
setupDefaults();
|
||||||
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
vendorId: 1,
|
||||||
|
canDeactivate: false,
|
||||||
|
openWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 101,
|
||||||
|
workOrderNumber: "WO-101",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Boiler repair/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
126
src/test/domain/vendors/api/vendors-api.test.ts
vendored
126
src/test/domain/vendors/api/vendors-api.test.ts
vendored
|
|
@ -4,12 +4,13 @@ import { API_PATHS } from "@/api/api-paths";
|
||||||
const apiGet = vi.fn();
|
const apiGet = vi.fn();
|
||||||
const apiPost = vi.fn();
|
const apiPost = vi.fn();
|
||||||
const apiPut = vi.fn();
|
const apiPut = vi.fn();
|
||||||
|
const apiDelete = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||||
apiDelete: vi.fn(),
|
apiDelete: (...args: unknown[]) => apiDelete(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
|
||||||
|
|
@ -19,15 +20,18 @@ describe("vendorsApi.getList", () => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
apiPost.mockReset();
|
apiPost.mockReset();
|
||||||
apiPut.mockReset();
|
apiPut.mockReset();
|
||||||
|
apiDelete.mockReset();
|
||||||
apiGet.mockResolvedValue({
|
apiGet.mockResolvedValue({
|
||||||
Data: [
|
Data: [
|
||||||
{
|
{
|
||||||
Id: 7,
|
Id: 7,
|
||||||
|
CompanyId: "co-1",
|
||||||
CompanyName: "Gateway Plumbing",
|
CompanyName: "Gateway Plumbing",
|
||||||
ContactName: "Adam Whyte",
|
ContactName: "Adam Whyte",
|
||||||
City: "St. Louis",
|
City: "St. Louis",
|
||||||
State: "MO",
|
State: "MO",
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
|
PreferredContact: "Email",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
TotalCount: 1,
|
TotalCount: 1,
|
||||||
|
|
@ -40,7 +44,13 @@ describe("vendorsApi.getList", () => {
|
||||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.vendor.getList, {
|
||||||
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
||||||
});
|
});
|
||||||
expect(result.items[0]).toMatchObject({ city: "St. Louis", state: "MO", isActive: false });
|
expect(result.items[0]).toMatchObject({
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
isActive: false,
|
||||||
|
companyId: "co-1",
|
||||||
|
preferredContact: "Email",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards all selected facets as repeated indexed query parameters", async () => {
|
it("forwards all selected facets as repeated indexed query parameters", async () => {
|
||||||
|
|
@ -116,9 +126,10 @@ describe("vendorsApi.getList", () => {
|
||||||
|
|
||||||
it("sends all editable prototype fields when creating and updating", async () => {
|
it("sends all editable prototype fields when creating and updating", async () => {
|
||||||
const values = {
|
const values = {
|
||||||
|
companyId: "42",
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
phone: "314-555-0198",
|
phone: "+1 314-555-0198",
|
||||||
companyPhone: "314-555-0100",
|
companyPhone: "314-555-0100",
|
||||||
email: "dispatch@gateway.test",
|
email: "dispatch@gateway.test",
|
||||||
address: "1 Market St",
|
address: "1 Market St",
|
||||||
|
|
@ -130,6 +141,7 @@ describe("vendorsApi.getList", () => {
|
||||||
notes: "Preferred after-hours vendor",
|
notes: "Preferred after-hours vendor",
|
||||||
totalJobs: 72,
|
totalJobs: 72,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
preferredContact: "Text",
|
||||||
};
|
};
|
||||||
apiPost.mockResolvedValueOnce({ data: values });
|
apiPost.mockResolvedValueOnce({ data: values });
|
||||||
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
apiPut.mockResolvedValueOnce({ data: { ...values, isActive: false } });
|
||||||
|
|
@ -139,7 +151,14 @@ describe("vendorsApi.getList", () => {
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenCalledWith(
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
API_PATHS.rest.vendors,
|
API_PATHS.rest.vendors,
|
||||||
expect.objectContaining({ companyName: "Gateway Plumbing", notes: values.notes }),
|
expect.objectContaining({
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
notes: values.notes,
|
||||||
|
companyId: 42,
|
||||||
|
preferredContact: "Text",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
|
companyPhone: "(314) 555-0100",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(apiPut).toHaveBeenCalledWith(
|
expect(apiPut).toHaveBeenCalledWith(
|
||||||
`${API_PATHS.rest.vendors}/7`,
|
`${API_PATHS.rest.vendors}/7`,
|
||||||
|
|
@ -148,4 +167,103 @@ describe("vendorsApi.getList", () => {
|
||||||
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||||
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
expect(apiPut.mock.calls[0]?.[1]).not.toHaveProperty("totalJobs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends a null companyId when free-text company has no company link", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ data: {} });
|
||||||
|
await vendorsApi.create({
|
||||||
|
companyName: "Independent Vendor LLC",
|
||||||
|
contactName: "Indy Tech",
|
||||||
|
preferredContact: "Phone",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.rest.vendors,
|
||||||
|
expect.objectContaining({ companyName: "Independent Vendor LLC", companyId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send a non-numeric company id to the integer backend contract", async () => {
|
||||||
|
apiPost.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await vendorsApi.create({
|
||||||
|
companyId: "co-1",
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiPost).toHaveBeenCalledWith(
|
||||||
|
API_PATHS.rest.vendors,
|
||||||
|
expect.objectContaining({ companyId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a legacy phone on read and unrelated write", async () => {
|
||||||
|
apiGet.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: 7,
|
||||||
|
companyName: "Legacy Vendor",
|
||||||
|
contactName: "Taylor",
|
||||||
|
phone: "555-1234 ext 9",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiPut.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
const vendor = await vendorsApi.getById(7);
|
||||||
|
expect(vendor.phone).toBe("555-1234 ext 9");
|
||||||
|
|
||||||
|
await vendorsApi.update(7, { ...vendor, notes: "Updated only the notes" });
|
||||||
|
expect(apiPut).toHaveBeenCalledWith(
|
||||||
|
`${API_PATHS.rest.vendors}/7`,
|
||||||
|
expect.objectContaining({ phone: "555-1234 ext 9", notes: "Updated only the notes" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the deactivation-impact payload including linked work orders", async () => {
|
||||||
|
apiGet.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
vendorId: 7,
|
||||||
|
canDeactivate: false,
|
||||||
|
openWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 101,
|
||||||
|
workOrderNumber: "WO-101",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2025-01-02",
|
||||||
|
dispatchId: 501,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const impact = await vendorsApi.getDeactivationImpact(7);
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(
|
||||||
|
`${API_PATHS.rest.vendors}/7/${API_PATHS.vendor.deactivationImpact}`,
|
||||||
|
);
|
||||||
|
expect(impact).toEqual({
|
||||||
|
vendorId: 7,
|
||||||
|
canDeactivate: false,
|
||||||
|
openWorkOrders: [
|
||||||
|
{
|
||||||
|
workOrderId: 101,
|
||||||
|
workOrderNumber: "WO-101",
|
||||||
|
workOrderTitle: "Boiler repair",
|
||||||
|
status: "Dispatched",
|
||||||
|
scheduledDate: "2025-01-02",
|
||||||
|
dispatchId: 501,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a vendor by id and surfaces the 409 error without retry swallowing", async () => {
|
||||||
|
apiDelete.mockResolvedValueOnce(undefined);
|
||||||
|
await vendorsApi.delete(7);
|
||||||
|
expect(apiDelete).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7`);
|
||||||
|
|
||||||
|
const conflict = Object.assign(new Error("Conflict"), { response: { status: 409 } });
|
||||||
|
apiDelete.mockRejectedValueOnce(conflict);
|
||||||
|
await expect(vendorsApi.delete(8)).rejects.toThrow("Conflict");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const validVendor = {
|
||||||
companyName: "Gateway Plumbing",
|
companyName: "Gateway Plumbing",
|
||||||
contactName: "Adam Whyte",
|
contactName: "Adam Whyte",
|
||||||
email: "dispatch@gateway.test",
|
email: "dispatch@gateway.test",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
googleMapsUrl: "https://maps.google.com/gateway",
|
googleMapsUrl: "https://maps.google.com/gateway",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -33,4 +34,29 @@ describe("vendorSchema", () => {
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts canonicalizable North American phones and rejects partials", () => {
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "", companyPhone: "" }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, companyPhone: "(314) 555-0100" }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "314-555-0198" }).success).toBe(true);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "+1 314 555 0198" }).success).toBe(true);
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, phone: "(314)" }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a nullable companyId and a known preferred contact value", () => {
|
||||||
|
expect(
|
||||||
|
vendorSchema.safeParse({ ...validVendor, companyId: null, preferredContact: "Text" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
vendorSchema.safeParse({ ...validVendor, companyId: 42, preferredContact: "Email" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown preferred contact value", () => {
|
||||||
|
expect(vendorSchema.safeParse({ ...validVendor, preferredContact: "Fax" }).success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
|
||||||
const apiGet = vi.fn();
|
const apiGet = vi.fn();
|
||||||
|
const apiPut = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
|
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||||
apiPost: vi.fn(),
|
apiPost: vi.fn(),
|
||||||
apiDelete: vi.fn(),
|
apiDelete: vi.fn(),
|
||||||
apiPostNoContent: vi.fn(),
|
apiPostNoContent: vi.fn(),
|
||||||
|
|
@ -15,6 +17,7 @@ import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
describe("workOrdersApi.getById", () => {
|
describe("workOrdersApi.getById", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
|
apiPut.mockReset();
|
||||||
apiGet.mockResolvedValue({
|
apiGet.mockResolvedValue({
|
||||||
Id: 10,
|
Id: 10,
|
||||||
title: "Test WO",
|
title: "Test WO",
|
||||||
|
|
@ -22,6 +25,22 @@ describe("workOrdersApi.getById", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates only the assigned vendor through the work-order-scoped endpoint", async () => {
|
||||||
|
apiPut.mockResolvedValueOnce(undefined);
|
||||||
|
const payload = {
|
||||||
|
workOrderId: 10,
|
||||||
|
contactName: "Taylor Tech",
|
||||||
|
preferredContact: "Text" as const,
|
||||||
|
phone: "(314) 555-0199",
|
||||||
|
email: "taylor@example.com",
|
||||||
|
notes: "Text before arrival",
|
||||||
|
};
|
||||||
|
|
||||||
|
await workOrdersApi.updateVendorFromWorkOrder(7, payload);
|
||||||
|
|
||||||
|
expect(apiPut).toHaveBeenCalledWith(`${API_PATHS.rest.vendors}/7/work-order-update`, payload);
|
||||||
|
});
|
||||||
|
|
||||||
it("calls GetWorkorderById with id query param, not path suffix", async () => {
|
it("calls GetWorkorderById with id query param, not path suffix", async () => {
|
||||||
await workOrdersApi.getById(10);
|
await workOrdersApi.getById(10);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper";
|
import {
|
||||||
|
mapUpliftRequest,
|
||||||
|
mapWorkOrderDispatchSummary,
|
||||||
|
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
|
|
||||||
|
describe("mapWorkOrderDispatchSummary", () => {
|
||||||
|
it("preserves the assigned vendor identity needed by inline editing", () => {
|
||||||
|
expect(
|
||||||
|
mapWorkOrderDispatchSummary({
|
||||||
|
Id: 21,
|
||||||
|
VendorId: 7,
|
||||||
|
VendorName: "Gateway Plumbing",
|
||||||
|
VendorEmail: "dispatch@gateway.test",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
id: 21,
|
||||||
|
vendorId: 7,
|
||||||
|
vendorName: "Gateway Plumbing",
|
||||||
|
vendorEmail: "dispatch@gateway.test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("mapUpliftRequest", () => {
|
describe("mapUpliftRequest", () => {
|
||||||
it("maps decided uplift fields from PascalCase payload", () => {
|
it("maps decided uplift fields from PascalCase payload", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue