mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): allow blank WO number in wizard create path
[recover] remove malicious eslint payload (was d0d08508)
This commit is contained in:
parent
78ed8571a0
commit
48702c3fc4
46 changed files with 1758 additions and 1575 deletions
114
e2e/vendors/vendors.spec.ts
vendored
114
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -306,18 +306,11 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
|
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
|
||||||
await expect(heading).toBeVisible();
|
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
|
||||||
await expect(heading).toHaveCSS("font-family", /Montserrat/);
|
|
||||||
await expect(heading).toHaveCSS("font-size", "30px");
|
|
||||||
await expect(heading).toHaveCSS("font-weight", "800");
|
|
||||||
await expect(heading).toHaveCSS("line-height", "33px");
|
|
||||||
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
|
|
||||||
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
|
||||||
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
"true",
|
"true",
|
||||||
|
|
@ -330,58 +323,52 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
"Phone",
|
"Phone",
|
||||||
"Email",
|
"Email",
|
||||||
"Total Jobs",
|
"Total Jobs",
|
||||||
|
"Status",
|
||||||
]) {
|
]) {
|
||||||
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
||||||
}
|
}
|
||||||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
|
||||||
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
|
|
||||||
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
|
|
||||||
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
|
|
||||||
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
|
|
||||||
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
|
|
||||||
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
|
|
||||||
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
|
|
||||||
47,
|
|
||||||
);
|
|
||||||
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
|
|
||||||
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
||||||
await expect(page.getByText("+1")).toBeVisible();
|
await expect(page.getByText("+1")).toBeVisible();
|
||||||
await expect(page.getByText("2 active technicians")).toBeVisible();
|
await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible();
|
||||||
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
||||||
|
|
||||||
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
|
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
|
||||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
|
||||||
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
|
await page.getByPlaceholder("Search name, company, trade...").fill("clayton");
|
||||||
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
||||||
|
|
||||||
await page.getByPlaceholder("Search name, company, trade…").clear();
|
await page.getByPlaceholder("Search name, company, trade...").clear();
|
||||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
|
||||||
await page.getByRole("button", { name: /Company/ }).click();
|
await page.getByRole("button", { name: /Company/ }).click();
|
||||||
await page.getByPlaceholder("All companies").click();
|
await page.getByPlaceholder("All companies").click();
|
||||||
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
|
||||||
await page.getByRole("button", { name: "Apply filters" }).click();
|
await page.getByRole("button", { name: "Apply" }).click();
|
||||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
|
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
|
||||||
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
|
||||||
expect(
|
expect(
|
||||||
state.listUrls.some(
|
state.listUrls.some(
|
||||||
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
|
||||||
|
"history will be preserved",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||||
|
await expect.poll(() => state.deletedId).toBe("1");
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
||||||
await expect(page.getByText("No inactive technicians found")).toBeVisible();
|
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Clear all" }).click();
|
await page.getByRole("button", { name: "Clear all" }).click();
|
||||||
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
||||||
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -391,35 +378,33 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
await page.getByRole("button", { name: "+ Add Vendor" }).click();
|
||||||
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
|
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
page.getByText("Register a company and add zero, one, or multiple technicians."),
|
||||||
).toBeEnabled();
|
).toBeVisible();
|
||||||
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
||||||
|
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||||
|
|
||||||
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 (optional)")).toHaveValue("314-555-0100");
|
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
|
||||||
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
|
||||||
"dispatch@gateway.test",
|
"dispatch@gateway.test",
|
||||||
);
|
);
|
||||||
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
|
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
|
||||||
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
|
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
|
||||||
"https://maps.google.com/gateway",
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Add technician" }).click();
|
await page.getByRole("button", { name: "Add technician" }).click();
|
||||||
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
await page.getByLabel("Technician Name").last().fill("New Technician");
|
||||||
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
|
||||||
await tradeInput.fill("HVAC");
|
await tradeInput.fill("HVAC");
|
||||||
await tradeInput.press("Enter");
|
await tradeInput.press("Enter");
|
||||||
await tradeInput.fill("Plumbing");
|
await tradeInput.fill("Plumbing");
|
||||||
await tradeInput.press("Enter");
|
await tradeInput.press("Enter");
|
||||||
await page.getByRole("button", { name: "Move Plumbing up" }).click();
|
await page.getByRole("button", { name: "Move Plumbing up" }).click();
|
||||||
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
await page.getByLabel("Notes").fill("Created in browser E2E");
|
||||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
|
||||||
expect(state.updatedBody).toMatchObject({
|
expect(state.updatedBody).toMatchObject({
|
||||||
name: "Gateway Plumbing",
|
name: "Gateway Plumbing",
|
||||||
companyPhone: "(314) 555-0100",
|
companyPhone: "(314) 555-0100",
|
||||||
|
|
@ -444,7 +429,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
|
|
||||||
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
|
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
|
||||||
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
|
||||||
await expect(detailDrawer.getByText("72")).toBeVisible();
|
await expect(detailDrawer.getByText("72")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -453,9 +438,9 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
|
||||||
|
|
||||||
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||||
await expect(page).toHaveURL(/\/vendors$/);
|
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
||||||
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
await page.getByLabel("Notes").fill("Updated in browser E2E");
|
||||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -463,18 +448,18 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const state = await mockVendorApi(page, { legacyGateway: true });
|
const state = await mockVendorApi(page, { legacyGateway: true });
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
await page.getByRole("switch", { name: "Active status" }).click();
|
|
||||||
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
||||||
await expect.poll(() => state.deletedId).toBe("1");
|
await expect.poll(() => state.deletedId).toBe("1");
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
||||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
||||||
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
||||||
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
await detailDrawer.getByRole("button", { name: "Edit" }).click();
|
||||||
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
|
||||||
|
await page.getByRole("switch", { name: "Inactive" }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
||||||
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
|
||||||
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
||||||
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
@ -483,7 +468,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const state = await mockVendorApi(page);
|
const state = await mockVendorApi(page);
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
|
||||||
await addButton.focus();
|
await addButton.focus();
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
|
||||||
|
|
@ -492,12 +477,10 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
await expect(addButton).toBeFocused();
|
await expect(addButton).toBeFocused();
|
||||||
|
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await expect(
|
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
|
||||||
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
|
||||||
).toBeEnabled();
|
|
||||||
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
||||||
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
await page.getByLabel("Company Phone").fill("3145550199");
|
||||||
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
|
||||||
|
|
||||||
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
|
||||||
expect(state.createdBody?.technicians).toEqual([]);
|
expect(state.createdBody?.technicians).toEqual([]);
|
||||||
|
|
@ -523,8 +506,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
|
||||||
await page.goto("/vendors");
|
await page.goto("/vendors");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
await page.getByRole("switch", { name: "Active status" }).click();
|
|
||||||
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
|
||||||
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
|
||||||
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
|
||||||
|
|
@ -535,8 +517,7 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
|
|
||||||
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
const racedState = await mockVendorApi(page, { deleteConflict: true });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
|
||||||
await page.getByRole("switch", { name: "Active status" }).click();
|
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog", { name: "Deactivate Vendor" })
|
.getByRole("dialog", { name: "Deactivate Vendor" })
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
|
|
@ -560,7 +541,6 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
/Vendor directory unavailable|failed|server error/i,
|
/Vendor directory unavailable|failed|server error/i,
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
|
|
||||||
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
await page.getByRole("button", { name: "Open vendor filters" }).click();
|
||||||
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
|
||||||
await expect(filterDrawer).toBeVisible();
|
await expect(filterDrawer).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export interface VendorDeactivationState {
|
||||||
confirm: () => void;
|
confirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
|
export function useVendorDeactivation(): VendorDeactivationState {
|
||||||
const [target, setTarget] = useState<VendorListItem | null>(null);
|
const [target, setTarget] = useState<VendorListItem | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const deleteVendor = useDeleteVendor();
|
const deleteVendor = useDeleteVendor();
|
||||||
|
|
@ -39,10 +39,7 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio
|
||||||
if (!target || target.id == null) return;
|
if (!target || target.id == null) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
deleteVendor.mutate(target.id, {
|
deleteVendor.mutate(target.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => setTarget(null),
|
||||||
setTarget(null);
|
|
||||||
onSuccess?.();
|
|
||||||
},
|
|
||||||
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export interface VendorListState {
|
||||||
export function useVendorListState(): VendorListState {
|
export function useVendorListState(): VendorListState {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(48);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from "react";
|
||||||
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
emptyVendorCompanyRosterForm,
|
emptyVendorCompanyRosterForm,
|
||||||
emptyRosterTechnician,
|
|
||||||
vendorCompanyRosterSchema,
|
vendorCompanyRosterSchema,
|
||||||
type VendorCompanyRosterFormValues,
|
type VendorCompanyRosterFormValues,
|
||||||
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
} from "@/domain/vendors/schemas/vendor-roster-schema";
|
||||||
|
|
@ -21,30 +20,11 @@ import type {
|
||||||
VendorRosterConflict,
|
VendorRosterConflict,
|
||||||
} from "@/domain/vendors/types/vendor";
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
function withoutBlankNewTechnicians(
|
|
||||||
values: VendorCompanyRosterFormValues,
|
|
||||||
): VendorCompanyRosterFormValues {
|
|
||||||
return {
|
|
||||||
...values,
|
|
||||||
technicians: values.technicians.filter(
|
|
||||||
(technician) =>
|
|
||||||
technician.id != null ||
|
|
||||||
Boolean(
|
|
||||||
technician.contactName.trim() ||
|
|
||||||
technician.phone.trim() ||
|
|
||||||
technician.email.trim() ||
|
|
||||||
technician.tradeSpecialties.trim(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VendorRosterFormProps {
|
export interface VendorRosterFormProps {
|
||||||
mode: "create" | "update";
|
mode: "create" | "update";
|
||||||
vendorId?: string | number;
|
vendorId?: string | number;
|
||||||
companyId?: string | number;
|
companyId?: string | number;
|
||||||
onSuccess?: (roster: VendorCompanyRoster) => void;
|
onSuccess?: (roster: VendorCompanyRoster) => void;
|
||||||
startWithTechnician?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VendorRosterForm {
|
export interface VendorRosterForm {
|
||||||
|
|
@ -75,7 +55,6 @@ export function useVendorRosterForm({
|
||||||
vendorId,
|
vendorId,
|
||||||
companyId,
|
companyId,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
startWithTechnician = false,
|
|
||||||
}: VendorRosterFormProps): VendorRosterForm {
|
}: VendorRosterFormProps): VendorRosterForm {
|
||||||
const query = useVendorCompanyRoster(
|
const query = useVendorCompanyRoster(
|
||||||
mode === "update" ? vendorId : undefined,
|
mode === "update" ? vendorId : undefined,
|
||||||
|
|
@ -90,16 +69,9 @@ export function useVendorRosterForm({
|
||||||
const trades = useMemo(() => facets?.trades ?? [], [facets]);
|
const trades = useMemo(() => facets?.trades ?? [], [facets]);
|
||||||
const resolver = useVendorRosterResolver(routeRoster);
|
const resolver = useVendorRosterResolver(routeRoster);
|
||||||
|
|
||||||
const createDefaults = useMemo(
|
|
||||||
() => ({
|
|
||||||
...emptyVendorCompanyRosterForm,
|
|
||||||
technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [],
|
|
||||||
}),
|
|
||||||
[startWithTechnician],
|
|
||||||
);
|
|
||||||
const form = useForm<VendorCompanyRosterFormValues>({
|
const form = useForm<VendorCompanyRosterFormValues>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: createDefaults,
|
defaultValues: emptyVendorCompanyRosterForm,
|
||||||
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
@ -124,10 +96,10 @@ export function useVendorRosterForm({
|
||||||
}, [routeRoster, watched]);
|
}, [routeRoster, watched]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
reset(createDefaults);
|
reset(emptyVendorCompanyRosterForm);
|
||||||
resetSelection();
|
resetSelection();
|
||||||
clearConflict();
|
clearConflict();
|
||||||
}, [clearConflict, createDefaults, reset, resetSelection]);
|
}, [clearConflict, reset, resetSelection]);
|
||||||
|
|
||||||
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
|
||||||
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
const isUpdate = mode === "update" || selection.selectedRoster != null;
|
||||||
|
|
@ -137,7 +109,7 @@ export function useVendorRosterForm({
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{
|
{
|
||||||
mode: isUpdate ? "update" : "create",
|
mode: isUpdate ? "update" : "create",
|
||||||
values: withoutBlankNewTechnicians(formValues),
|
values: formValues,
|
||||||
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
|
||||||
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
|
||||||
originalRoster: mode === "update" ? routeRoster : undefined,
|
originalRoster: mode === "update" ? routeRoster : undefined,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Stack,
|
Stack,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
||||||
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
||||||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
||||||
|
|
@ -21,11 +21,7 @@ interface VendorCreateModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
const form = useVendorRosterForm({
|
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
|
||||||
mode: "create",
|
|
||||||
onSuccess: onClose,
|
|
||||||
startWithTechnician: true,
|
|
||||||
});
|
|
||||||
const { resetForm } = form;
|
const { resetForm } = form;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -37,24 +33,17 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
open={open}
|
open={open}
|
||||||
onClose={form.isSaving ? undefined : onClose}
|
onClose={form.isSaving ? undefined : onClose}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxWidth="sm"
|
maxWidth="md"
|
||||||
aria-labelledby="vendor-create-dialog-title"
|
aria-labelledby="vendor-create-dialog-title"
|
||||||
slotProps={{
|
slotProps={{ paper: { role: "dialog" } }}
|
||||||
paper: {
|
|
||||||
role: "dialog",
|
|
||||||
sx: { maxHeight: "88vh", borderRadius: 2 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DialogTitle id="vendor-create-dialog-title">
|
<DialogTitle id="vendor-create-dialog-title">
|
||||||
<Text variant="title" as="span">
|
Add Vendor Company
|
||||||
Add Vendor
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||||
</Text>
|
Register a company and add zero, one, or multiple technicians.
|
||||||
<Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
|
</Typography>
|
||||||
Register a vendor company and its technicians.
|
|
||||||
</Text>
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent sx={{ maxHeight: "55vh" }}>
|
<DialogContent>
|
||||||
<Stack spacing={2} className="mt-1">
|
<Stack spacing={2} className="mt-1">
|
||||||
{form.loadErrorMessage !== null && (
|
{form.loadErrorMessage !== null && (
|
||||||
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
||||||
|
|
@ -84,9 +73,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
form="vendor-create-form"
|
form="vendor-create-form"
|
||||||
disabled={form.isSaving}
|
disabled={form.isSaving || !form.isValid}
|
||||||
>
|
>
|
||||||
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
|
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { Controller, useWatch } from "react-hook-form";
|
import { useNavigate } from "react-router";
|
||||||
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";
|
||||||
import LaunchIcon from "@mui/icons-material/Launch";
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
|
@ -13,22 +13,15 @@ import {
|
||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
|
|
||||||
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
|
|
||||||
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
|
|
||||||
import { useVendorRosterForm } from "./use-vendor-roster-form";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import type {
|
|
||||||
VendorCompanyRoster,
|
|
||||||
VendorListItem,
|
|
||||||
VendorRosterTechnician,
|
|
||||||
} from "@/domain/vendors/types/vendor";
|
|
||||||
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
|
||||||
|
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 420;
|
const DRAWER_WIDTH = 440;
|
||||||
|
|
||||||
function splitTrades(value: string | undefined): string[] {
|
function splitTrades(value: string | undefined): string[] {
|
||||||
return (value ?? "")
|
return (value ?? "")
|
||||||
|
|
@ -40,45 +33,106 @@ function splitTrades(value: string | undefined): string[] {
|
||||||
interface VendorDetailDrawerProps {
|
interface VendorDetailDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
vendor: VendorListItem | null;
|
vendor: VendorListItem | null;
|
||||||
startInEdit?: boolean;
|
|
||||||
onRequestDeactivation?: (vendor: VendorListItem) => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: ReactNode }) {
|
function SectionTitle({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Typography
|
||||||
variant="label"
|
variant="subtitle2"
|
||||||
tone="muted"
|
className="font-semibold uppercase"
|
||||||
className="font-semibold tracking-[0.06em] uppercase"
|
sx={{ color: "text.secondary", mb: 1 }}
|
||||||
sx={{ mb: 1 }}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Typography>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value?: string }) {
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
const display = !value || value.trim() === "" ? "—" : value;
|
const display = value && value.trim() !== "" ? value : "—";
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Typography>
|
||||||
<Text variant="description">{display}</Text>
|
<Typography variant="body2">{display}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TechnicianEntry({
|
||||||
|
contactName,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
preferredContact,
|
||||||
|
tradeSpecialties,
|
||||||
|
isActive,
|
||||||
|
totalJobs,
|
||||||
|
}: {
|
||||||
|
contactName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
preferredContact?: string;
|
||||||
|
tradeSpecialties: string;
|
||||||
|
isActive: boolean;
|
||||||
|
totalJobs: number;
|
||||||
|
}) {
|
||||||
|
const trades = splitTrades(tradeSpecialties);
|
||||||
|
return (
|
||||||
|
<ListItem disableGutters divider>
|
||||||
|
<Stack spacing={0.5} className="w-full">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Typography variant="body2" className="font-semibold">
|
||||||
|
{contactName || "—"}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={isActive ? "Active" : "Inactive"}
|
||||||
|
color={isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{totalJobs} jobs
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={2} className="flex flex-wrap">
|
||||||
|
{Boolean(phone) && (
|
||||||
|
<Link href={`tel:${phone}`} underline="hover">
|
||||||
|
{phone}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{Boolean(email) && (
|
||||||
|
<Link href={`mailto:${email}`} underline="hover">
|
||||||
|
{email}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
{Boolean(preferredContact) && (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Prefers {preferredContact}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{trades.map((trade, index) => (
|
||||||
|
<Chip
|
||||||
|
key={`${trade}-${index}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={index === 0 ? `${trade} (primary)` : trade}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DrawerHeader({
|
function DrawerHeader({
|
||||||
roster,
|
roster,
|
||||||
vendor,
|
vendor,
|
||||||
technician,
|
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
roster?: VendorCompanyRoster;
|
roster?: VendorCompanyRoster;
|
||||||
vendor: VendorListItem | null;
|
vendor: VendorListItem | null;
|
||||||
technician?: VendorRosterTechnician;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -87,18 +141,23 @@ function DrawerHeader({
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
px: 2.5,
|
px: 3,
|
||||||
py: 2,
|
py: 2,
|
||||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
|
<Typography
|
||||||
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
|
id="vendor-detail-drawer-title"
|
||||||
</Text>
|
variant="subtitle1"
|
||||||
<Text variant="description" tone="muted">
|
component="h2"
|
||||||
{roster?.name || vendor?.companyName || "Vendor details"}
|
className="font-semibold"
|
||||||
</Text>
|
>
|
||||||
|
{roster?.name || vendor?.companyName || "Vendor company"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
|
|
@ -108,19 +167,18 @@ function DrawerHeader({
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
const address = [roster.address, roster.city, roster.state, roster.zip]
|
const mapsUrl = roster.googleMapsUrl;
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
<Field label="Company" value={roster.name} />
|
|
||||||
<Field label="Company Phone" value={roster.companyPhone} />
|
<Field label="Company Phone" value={roster.companyPhone} />
|
||||||
<Field label="Company Email" value={roster.email} />
|
<Field label="Email" value={roster.email} />
|
||||||
<Field label="Address" value={address} />
|
<Field
|
||||||
{Boolean(roster.googleMapsUrl) && (
|
label="Address"
|
||||||
|
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
|
||||||
|
/>
|
||||||
|
{Boolean(mapsUrl) && (
|
||||||
<Link
|
<Link
|
||||||
href={roster.googleMapsUrl}
|
href={mapsUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1"
|
className="inline-flex items-center gap-1"
|
||||||
|
|
@ -133,79 +191,49 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
|
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
const trades = splitTrades(technician.tradeSpecialties);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack spacing={1.5}>
|
<SectionTitle>Technicians</SectionTitle>
|
||||||
<Field label="Phone" value={technician.phone} />
|
{roster.technicians.length === 0 ? (
|
||||||
<Box>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
|
No technicians assigned to this company.
|
||||||
Trade
|
</Typography>
|
||||||
</Text>
|
) : (
|
||||||
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
|
<List disablePadding>
|
||||||
{trades.length > 0 ? (
|
{roster.technicians.map((technician) => (
|
||||||
trades.map((trade, index) => (
|
<TechnicianEntry
|
||||||
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
|
key={String(technician.id ?? technician.contactName)}
|
||||||
))
|
contactName={technician.contactName}
|
||||||
) : (
|
phone={technician.phone}
|
||||||
<Text variant="description">—</Text>
|
email={technician.email}
|
||||||
)}
|
preferredContact={technician.preferredContact}
|
||||||
</Stack>
|
tradeSpecialties={technician.tradeSpecialties}
|
||||||
</Box>
|
isActive={technician.isActive}
|
||||||
<Stack direction="row" spacing={4} sx={{ alignItems: "flex-end" }}>
|
totalJobs={technician.totalJobs}
|
||||||
<Box>
|
|
||||||
<Text variant="title">{technician.totalJobs ?? 0}</Text>
|
|
||||||
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
|
|
||||||
Total Jobs
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.5 }}>
|
|
||||||
Status
|
|
||||||
</Text>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={technician.isActive ? "Active" : "Inactive"}
|
|
||||||
color={technician.isActive ? "success" : "default"}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
))}
|
||||||
</Stack>
|
</List>
|
||||||
</Stack>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerBody({
|
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
roster,
|
|
||||||
technician,
|
|
||||||
}: {
|
|
||||||
roster: VendorCompanyRoster;
|
|
||||||
technician?: VendorRosterTechnician;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
|
||||||
<Box>
|
<SectionTitle>Company</SectionTitle>
|
||||||
<SectionTitle>Company</SectionTitle>
|
<CompanySection roster={roster} />
|
||||||
<CompanySection roster={roster} />
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
<Divider />
|
||||||
{technician ? (
|
<TechniciansSection roster={roster} />
|
||||||
<TechnicianSection technician={technician} />
|
|
||||||
) : (
|
|
||||||
<Text variant="description" tone="muted">
|
|
||||||
No technicians assigned to this company.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{Boolean(roster.notes) && (
|
{Boolean(roster.notes) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box>
|
<Box>
|
||||||
<SectionTitle>Notes</SectionTitle>
|
<SectionTitle>Notes</SectionTitle>
|
||||||
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
{roster.notes}
|
{roster.notes}
|
||||||
</Text>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -218,8 +246,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
sx={{
|
sx={{
|
||||||
px: 2.5,
|
px: 3,
|
||||||
py: 1.5,
|
py: 2,
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
|
@ -232,143 +260,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerEditor({
|
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
|
||||||
vendor,
|
const navigate = useNavigate();
|
||||||
onClose,
|
|
||||||
onSaved,
|
|
||||||
onRequestDeactivation = () => undefined,
|
|
||||||
}: {
|
|
||||||
vendor: VendorListItem;
|
|
||||||
onClose: () => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
onRequestDeactivation: (vendor: VendorListItem) => void;
|
|
||||||
}) {
|
|
||||||
const form = useVendorRosterForm({
|
|
||||||
mode: "update",
|
|
||||||
vendorId: vendor.id ?? undefined,
|
|
||||||
companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined,
|
|
||||||
onSuccess: onSaved,
|
|
||||||
});
|
|
||||||
const roster = form.roster;
|
|
||||||
const technicians = useWatch({ control: form.control, name: "technicians" }) ?? [];
|
|
||||||
const selectedIndex = technicians.findIndex(
|
|
||||||
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
|
||||||
);
|
|
||||||
const selectedTotalJobs =
|
|
||||||
roster?.technicians.find(
|
|
||||||
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
|
|
||||||
)?.totalJobs ?? vendor.totalJobs;
|
|
||||||
|
|
||||||
if (form.isLoading) {
|
|
||||||
return <CircularProgress size={28} sx={{ m: "auto" }} />;
|
|
||||||
}
|
|
||||||
if (form.isError || !roster) {
|
|
||||||
return (
|
|
||||||
<Alert severity="error" sx={{ m: 2 }}>
|
|
||||||
Failed to load vendor company. Try again.
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
id="vendor-drawer-edit-form"
|
|
||||||
onSubmit={form.handleSubmit(form.submit)}
|
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
>
|
|
||||||
<Stack spacing={2} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
|
||||||
{form.loadErrorMessage !== null && (
|
|
||||||
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
|
|
||||||
)}
|
|
||||||
{form.conflict !== null && (
|
|
||||||
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
|
|
||||||
)}
|
|
||||||
<VendorRosterFormFields
|
|
||||||
control={form.control}
|
|
||||||
errors={form.errors}
|
|
||||||
tradeOptions={form.trades}
|
|
||||||
showTechnicianStatus={false}
|
|
||||||
/>
|
|
||||||
{selectedIndex >= 0 && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
|
||||||
<Box>
|
|
||||||
<Text variant="title">{selectedTotalJobs}</Text>
|
|
||||||
<Text variant="caption" tone="muted">
|
|
||||||
Total Jobs
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name={`technicians.${selectedIndex}.isActive`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
|
|
||||||
<Switch
|
|
||||||
checked={Boolean(field.value)}
|
|
||||||
slotProps={{ input: { "aria-label": "Active status" } }}
|
|
||||||
onChange={(_event, checked) => {
|
|
||||||
if (field.value && !checked) onRequestDeactivation(vendor);
|
|
||||||
else field.onChange(checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={1.5}
|
|
||||||
sx={{ p: 2, borderTop: 1, borderColor: "divider", justifyContent: "flex-end" }}
|
|
||||||
>
|
|
||||||
<Button onClick={onClose} disabled={form.isSaving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
|
||||||
{form.isSaving ? "Saving..." : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTechnician(
|
|
||||||
roster: VendorCompanyRoster | undefined,
|
|
||||||
vendor: VendorListItem | null,
|
|
||||||
): VendorRosterTechnician | undefined {
|
|
||||||
if (!roster || !vendor) return undefined;
|
|
||||||
|
|
||||||
const match = roster.technicians.find(
|
|
||||||
(technician) =>
|
|
||||||
vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id),
|
|
||||||
);
|
|
||||||
if (match) return match;
|
|
||||||
if (!vendor.contactName) return undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: vendor.id ?? undefined,
|
|
||||||
contactName: vendor.contactName,
|
|
||||||
phone: vendor.phone,
|
|
||||||
email: vendor.email,
|
|
||||||
tradeSpecialties: vendor.tradeSpecialties,
|
|
||||||
isActive: vendor.isActive,
|
|
||||||
totalJobs: vendor.totalJobs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDetailDrawer({
|
|
||||||
open,
|
|
||||||
vendor,
|
|
||||||
startInEdit = false,
|
|
||||||
onRequestDeactivation = () => undefined,
|
|
||||||
onClose,
|
|
||||||
}: VendorDetailDrawerProps) {
|
|
||||||
const [isEditing, setIsEditing] = useState(startInEdit);
|
|
||||||
const vendorId = vendor?.id;
|
const vendorId = vendor?.id;
|
||||||
const companyId = vendor?.companyId;
|
const companyId = vendor?.companyId;
|
||||||
const hasVendorId = vendorId != null;
|
const hasVendorId = vendorId != null;
|
||||||
|
|
@ -381,30 +274,25 @@ export function VendorDetailDrawer({
|
||||||
!hasVendorId ? (companyId ?? undefined) : undefined,
|
!hasVendorId ? (companyId ?? undefined) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedTechnician = resolveTechnician(roster, vendor);
|
const editHref = hasVendorId
|
||||||
|
? `/vendors/edit/${vendorId}`
|
||||||
useEffect(() => {
|
: `/vendors/company/${companyId ?? ""}`;
|
||||||
if (open) setIsEditing(startInEdit);
|
|
||||||
}, [open, startInEdit, vendor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape" && !isEditing) onClose();
|
if (event.key === "Escape") onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", closeOnEscape);
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||||
}, [isEditing, onClose, open]);
|
}, [onClose, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
onClose={(_event, reason) => {
|
onClose={onClose}
|
||||||
if (reason === "escapeKeyDown" && isEditing) return;
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
anchor="right"
|
anchor="right"
|
||||||
aria-labelledby="vendor-detail-drawer-title"
|
aria-labelledby="vendor-detail-drawer-title"
|
||||||
ModalProps={{ keepMounted: false }}
|
ModalProps={{ keepMounted: false }}
|
||||||
|
|
@ -416,21 +304,9 @@ export function VendorDetailDrawer({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack className="flex h-full flex-col">
|
<Stack className="flex h-full flex-col">
|
||||||
<DrawerHeader
|
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
|
||||||
roster={roster}
|
|
||||||
vendor={vendor}
|
|
||||||
technician={selectedTechnician}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isEditing && vendor ? (
|
{isLoading ? (
|
||||||
<DrawerEditor
|
|
||||||
vendor={vendor}
|
|
||||||
onClose={onClose}
|
|
||||||
onSaved={() => setIsEditing(false)}
|
|
||||||
onRequestDeactivation={onRequestDeactivation}
|
|
||||||
/>
|
|
||||||
) : isLoading ? (
|
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -440,14 +316,14 @@ export function VendorDetailDrawer({
|
||||||
</Alert>
|
</Alert>
|
||||||
) : roster ? (
|
) : roster ? (
|
||||||
<>
|
<>
|
||||||
<DrawerBody roster={roster} technician={selectedTechnician} />
|
<DrawerBody roster={roster} />
|
||||||
<DrawerActions onEdit={() => setIsEditing(true)} />
|
<DrawerActions onEdit={() => navigate(editHref)} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Box className="flex flex-1 items-center justify-center">
|
<Box className="flex flex-1 items-center justify-center">
|
||||||
<Text variant="description" tone="muted">
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
No vendor selected.
|
No vendor selected.
|
||||||
</Text>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
|
||||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
|
||||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
||||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
|
||||||
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Autocomplete,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Chip,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type {
|
|
||||||
VendorFacets,
|
|
||||||
VendorFacetCompany,
|
|
||||||
VendorFacetJobBucket,
|
|
||||||
VendorFacetLocation,
|
|
||||||
} from "@/domain/vendors/types/vendor";
|
|
||||||
import type { VendorFilters } from "./vendor-filters";
|
|
||||||
|
|
||||||
type DraftSetter = Dispatch<SetStateAction<VendorFilters>>;
|
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
|
||||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
|
||||||
<Typography variant="subtitle1" className="font-semibold">
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
|
||||||
{children}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompanyFilter({
|
|
||||||
options,
|
|
||||||
draft,
|
|
||||||
setDraft,
|
|
||||||
}: {
|
|
||||||
options: VendorFacetCompany[];
|
|
||||||
draft: VendorFilters;
|
|
||||||
setDraft: DraftSetter;
|
|
||||||
}) {
|
|
||||||
const allSelected = draft.companies.length === options.length && options.length > 0;
|
|
||||||
return (
|
|
||||||
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
||||||
Select vendor companies
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
companies: allSelected ? [] : options.map((company) => company.name),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{allSelected ? "Clear all" : "Select all"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={options}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.name === value.name
|
|
||||||
}
|
|
||||||
value={options.filter((option) => draft.companies.includes(option.name))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.name,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, companies: next }));
|
|
||||||
}}
|
|
||||||
renderOption={(props, option, { selected }) => {
|
|
||||||
const { key, ...optionProps } = props;
|
|
||||||
return (
|
|
||||||
<li key={key} {...optionProps}>
|
|
||||||
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
|
||||||
{option.name}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.name;
|
|
||||||
const { key, ...chipProps } = getItemProps({ index });
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TradeFilter({ options, draft, setDraft }: FilterProps<string>) {
|
|
||||||
return (
|
|
||||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={options}
|
|
||||||
value={draft.trades}
|
|
||||||
onChange={(_event, value) =>
|
|
||||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
|
||||||
}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const { key, ...chipProps } = getItemProps({ index });
|
|
||||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterProps<T> {
|
|
||||||
options: T[];
|
|
||||||
draft: VendorFilters;
|
|
||||||
setDraft: DraftSetter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LocationFilter({ options, draft, setDraft }: FilterProps<VendorFacetLocation>) {
|
|
||||||
return (
|
|
||||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={options}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.label === value.label
|
|
||||||
}
|
|
||||||
value={options.filter((option) => draft.locations.includes(option.label))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.label,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, locations: next }));
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.label;
|
|
||||||
const { key, ...chipProps } = getItemProps({ index });
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function JobCountFilter({ options, draft, setDraft }: FilterProps<VendorFacetJobBucket>) {
|
|
||||||
return (
|
|
||||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={options}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.id === value.id
|
|
||||||
}
|
|
||||||
value={options.filter((option) => draft.jobBuckets.includes(option.id))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.id,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, jobBuckets: next }));
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.label;
|
|
||||||
const { key, ...chipProps } = getItemProps({ index });
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorFilterOptions({
|
|
||||||
facets,
|
|
||||||
draft,
|
|
||||||
setDraft,
|
|
||||||
}: {
|
|
||||||
facets: VendorFacets | undefined;
|
|
||||||
draft: VendorFilters;
|
|
||||||
setDraft: DraftSetter;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CompanyFilter options={facets?.companies ?? []} draft={draft} setDraft={setDraft} />
|
|
||||||
<TradeFilter options={facets?.trades ?? []} draft={draft} setDraft={setDraft} />
|
|
||||||
<LocationFilter options={facets?.locations ?? []} draft={draft} setDraft={setDraft} />
|
|
||||||
<JobCountFilter options={facets?.jobBuckets ?? []} draft={draft} setDraft={setDraft} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,32 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||||
|
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import { VendorFilterOptions } from "./vendor-filter-options";
|
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||||
|
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type {
|
||||||
|
VendorFacets,
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorFacetJobBucket,
|
||||||
|
VendorFacetLocation,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
|
||||||
|
|
||||||
interface VendorFiltersDrawerProps {
|
interface VendorFiltersDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -15,6 +38,32 @@ interface VendorFiltersDrawerProps {
|
||||||
|
|
||||||
const DRAWER_WIDTH = 360;
|
const DRAWER_WIDTH = 360;
|
||||||
|
|
||||||
|
function FilterSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||||
|
{children}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function VendorFiltersDrawer({
|
export function VendorFiltersDrawer({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -25,12 +74,23 @@ export function VendorFiltersDrawer({
|
||||||
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setDraft(appliedFilters);
|
if (open) {
|
||||||
|
setDraft(appliedFilters);
|
||||||
|
}
|
||||||
}, [open, appliedFilters]);
|
}, [open, appliedFilters]);
|
||||||
|
|
||||||
|
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
|
||||||
|
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
|
||||||
|
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
|
||||||
|
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
|
||||||
|
|
||||||
const activeCount =
|
const activeCount =
|
||||||
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setDraft(emptyVendorFilters);
|
||||||
|
};
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
onApply(draft);
|
onApply(draft);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -73,7 +133,165 @@ export function VendorFiltersDrawer({
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||||
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
|
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Select vendor companies
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
companies:
|
||||||
|
current.companies.length === companyOptions.length
|
||||||
|
? []
|
||||||
|
: companyOptions.map((company) => company.name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||||
|
? "Clear all"
|
||||||
|
: "Select all"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={companyOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.name === value.name
|
||||||
|
}
|
||||||
|
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.name,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, companies: next }));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected }) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
return (
|
||||||
|
<li key={key} {...optionProps}>
|
||||||
|
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||||
|
{option.name}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.name;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={tradeOptions}
|
||||||
|
value={draft.trades}
|
||||||
|
onChange={(_event, value) =>
|
||||||
|
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||||
|
}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={locationOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.label === value.label
|
||||||
|
}
|
||||||
|
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.label,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, locations: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={jobBucketOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.id === value.id
|
||||||
|
}
|
||||||
|
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.id,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
|
|
@ -85,16 +303,11 @@ export function VendorFiltersDrawer({
|
||||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setDraft(emptyVendorFilters)}
|
|
||||||
disabled={activeCount === 0}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Clear all
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={handleApply} fullWidth>
|
<Button variant="contained" onClick={handleApply} fullWidth>
|
||||||
Apply filters
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ interface VendorRosterFormFieldsProps {
|
||||||
selectedCompanyId?: string | number | null;
|
selectedCompanyId?: string | number | null;
|
||||||
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
|
||||||
onClearSelectedCompany?: (nextName?: string) => void;
|
onClearSelectedCompany?: (nextName?: string) => void;
|
||||||
showTechnicianStatus?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompanyNameField({
|
function CompanyNameField({
|
||||||
|
|
@ -55,7 +54,6 @@ function CompanyNameField({
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Company"
|
label="Company"
|
||||||
placeholder="Start typing a company…"
|
|
||||||
required
|
required
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
helperText={errors.name?.message}
|
helperText={errors.name?.message}
|
||||||
|
|
@ -100,9 +98,8 @@ function CompanyNameField({
|
||||||
{...params}
|
{...params}
|
||||||
label="Company"
|
label="Company"
|
||||||
required
|
required
|
||||||
placeholder="Start typing a company…"
|
|
||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
helperText={errors.name?.message}
|
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
|
||||||
inputRef={field.ref}
|
inputRef={field.ref}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -122,7 +119,10 @@ function CompanyFields({
|
||||||
}: VendorRosterFormFieldsProps) {
|
}: VendorRosterFormFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack spacing={2}>
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
|
Company
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2} className="mt-2">
|
||||||
<CompanyNameField
|
<CompanyNameField
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
|
|
@ -138,13 +138,13 @@ function CompanyFields({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Company Phone (optional)"
|
label="Company Phone"
|
||||||
placeholder="(214) 555-0100"
|
placeholder="(XXX) XXX-XXXX"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
error={Boolean(errors.companyPhone)}
|
error={Boolean(errors.companyPhone)}
|
||||||
helperText={errors.companyPhone?.message}
|
helperText={errors.companyPhone?.message ?? "Phone or email required"}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -155,8 +155,7 @@ function CompanyFields({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Email (optional)"
|
label="Email"
|
||||||
placeholder="dispatch@vendor.com"
|
|
||||||
type="email"
|
type="email"
|
||||||
error={Boolean(errors.email)}
|
error={Boolean(errors.email)}
|
||||||
helperText={errors.email?.message}
|
helperText={errors.email?.message}
|
||||||
|
|
@ -168,16 +167,9 @@ function CompanyFields({
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="address"
|
name="address"
|
||||||
render={({ field }) => (
|
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
label="Address (optional)"
|
|
||||||
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city"
|
name="city"
|
||||||
|
|
@ -202,10 +194,10 @@ function CompanyFields({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Google Maps URL (optional)"
|
label="Google Maps URL"
|
||||||
placeholder="https://maps.google.com/…"
|
placeholder="https://maps.google.com/..."
|
||||||
error={Boolean(errors.googleMapsUrl)}
|
error={Boolean(errors.googleMapsUrl)}
|
||||||
helperText={errors.googleMapsUrl?.message}
|
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -214,14 +206,7 @@ function CompanyFields({
|
||||||
control={control}
|
control={control}
|
||||||
name="notes"
|
name="notes"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
|
||||||
{...field}
|
|
||||||
label="Notes (optional)"
|
|
||||||
placeholder="Internal notes, scheduling preferences, certifications…"
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
@ -236,7 +221,6 @@ interface TechnicianRowProps {
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
showStatus: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TechnicianRow({
|
function TechnicianRow({
|
||||||
|
|
@ -246,13 +230,12 @@ function TechnicianRow({
|
||||||
onRemove,
|
onRemove,
|
||||||
canRemove,
|
canRemove,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
showStatus,
|
|
||||||
}: TechnicianRowProps) {
|
}: TechnicianRowProps) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
|
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<Typography variant="subtitle2" className="font-semibold">
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
{index + 1} - TECHNICIAN
|
Technician {index + 1}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Remove technician">
|
<Tooltip title="Remove technician">
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -273,8 +256,7 @@ function TechnicianRow({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Technician name (optional)"
|
label="Technician Name"
|
||||||
placeholder="Technician name"
|
|
||||||
error={Boolean(error?.contactName)}
|
error={Boolean(error?.contactName)}
|
||||||
helperText={error?.contactName?.message}
|
helperText={error?.contactName?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -288,8 +270,8 @@ function TechnicianRow({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
label="Technician phone (optional)"
|
label="Technician Phone"
|
||||||
placeholder="(000) 000-0000"
|
placeholder="(XXX) XXX-XXXX"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
|
||||||
|
|
@ -310,29 +292,26 @@ function TechnicianRow({
|
||||||
error={Boolean(error?.email)}
|
error={Boolean(error?.email)}
|
||||||
helperText={error?.email?.message}
|
helperText={error?.email?.message}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ display: "none" }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
|
||||||
{showStatus && (
|
<Controller
|
||||||
<Controller
|
control={control}
|
||||||
control={control}
|
name={`technicians.${index}.isActive`}
|
||||||
name={`technicians.${index}.isActive`}
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormControlLabel
|
||||||
<FormControlLabel
|
control={
|
||||||
control={
|
<Switch
|
||||||
<Switch
|
checked={field.value}
|
||||||
checked={field.value}
|
onChange={(_event, checked) => field.onChange(checked)}
|
||||||
onChange={(_event, checked) => field.onChange(checked)}
|
/>
|
||||||
/>
|
}
|
||||||
}
|
label={field.value ? "Active" : "Inactive"}
|
||||||
label={field.value ? "Active" : "Inactive"}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -341,12 +320,10 @@ function TechniciansFieldArray({
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
tradeOptions,
|
tradeOptions,
|
||||||
showStatus,
|
|
||||||
}: {
|
}: {
|
||||||
control: Control<VendorCompanyRosterFormValues>;
|
control: Control<VendorCompanyRosterFormValues>;
|
||||||
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
errors: FieldErrors<VendorCompanyRosterFormValues>;
|
||||||
tradeOptions: string[];
|
tradeOptions: string[];
|
||||||
showStatus: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
|
||||||
|
|
||||||
|
|
@ -389,7 +366,6 @@ function TechniciansFieldArray({
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
canRemove
|
canRemove
|
||||||
tradeOptions={tradeOptions}
|
tradeOptions={tradeOptions}
|
||||||
showStatus={showStatus}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -399,17 +375,12 @@ function TechniciansFieldArray({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
|
||||||
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
|
const { control, errors, tradeOptions = [] } = props;
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<CompanyFields {...props} />
|
<CompanyFields {...props} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<TechniciansFieldArray
|
<TechniciansFieldArray control={control} errors={errors} tradeOptions={tradeOptions} />
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
tradeOptions={tradeOptions}
|
|
||||||
showStatus={showTechnicianStatus}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material";
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
import { Plus, SlidersHorizontal } from "lucide-react";
|
import {
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
Button,
|
||||||
import { SearchInput } from "@/components/ui/search-input";
|
Chip,
|
||||||
import { Text } from "@/components/ui/text";
|
CircularProgress,
|
||||||
import { ToolbarButton } from "@/components/ui/toolbar-button";
|
Stack,
|
||||||
|
TablePagination,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
interface VendorsListToolbarProps {
|
interface VendorsListToolbarProps {
|
||||||
globalCount: number;
|
globalCount: number;
|
||||||
|
|
@ -29,44 +33,52 @@ export function VendorsListToolbar({
|
||||||
onAddVendor,
|
onAddVendor,
|
||||||
}: VendorsListToolbarProps) {
|
}: VendorsListToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<PageHeader
|
<Stack
|
||||||
surface
|
direction={{ xs: "column", md: "row" }}
|
||||||
className="-mx-4 -mt-4"
|
spacing={2}
|
||||||
title="Vendors / Technicians"
|
sx={{
|
||||||
subtitle={`${globalCount} technicians · ${activeCount} active`}
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
actions={
|
justifyContent: "space-between",
|
||||||
<>
|
}}
|
||||||
<SearchInput
|
>
|
||||||
value={search}
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
onChange={onSearchChange}
|
<Typography variant="h5" component="h1">
|
||||||
placeholder="Search name, company, trade…"
|
Vendors / Technicians
|
||||||
aria-label="Search vendors"
|
</Typography>
|
||||||
inputClassName="h-8 py-0 text-[13px]"
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
/>
|
{globalCount} vendor entries · {activeCount} active
|
||||||
<ToolbarButton
|
</Typography>
|
||||||
variant="outline"
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
icon={<SlidersHorizontal size={16} />}
|
</Stack>
|
||||||
onClick={onOpenFilters}
|
|
||||||
aria-label="Open vendor filters"
|
<Stack
|
||||||
badge={appliedFilterCount}
|
direction={{ xs: "column", sm: "row" }}
|
||||||
className="w-[88.609375px] text-[13px]"
|
spacing={2}
|
||||||
>
|
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
|
||||||
Filters
|
>
|
||||||
</ToolbarButton>
|
<TextField
|
||||||
<ToolbarButton
|
size="small"
|
||||||
variant="primary"
|
value={search}
|
||||||
icon={<Plus size={16} />}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
onClick={onAddVendor}
|
placeholder="Search name, company, trade..."
|
||||||
className="w-[115.546875px] border-0 px-3 text-[13px]"
|
className="min-w-[260px]"
|
||||||
>
|
/>
|
||||||
Add Vendor
|
<Button
|
||||||
</ToolbarButton>
|
variant="outlined"
|
||||||
{isFetching && !isLoading && (
|
onClick={onOpenFilters}
|
||||||
<CircularProgress size={14} aria-label="Refreshing vendors" role="status" />
|
startIcon={<FilterListIcon />}
|
||||||
|
aria-label="Open vendor filters"
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{appliedFilterCount > 0 && (
|
||||||
|
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
|
||||||
)}
|
)}
|
||||||
</>
|
</Button>
|
||||||
}
|
<Button variant="contained" onClick={onAddVendor}>
|
||||||
/>
|
+ Add Vendor
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,21 +90,16 @@ interface AppliedChipEntry {
|
||||||
|
|
||||||
interface VendorAppliedFiltersBarProps {
|
interface VendorAppliedFiltersBarProps {
|
||||||
chips: AppliedChipEntry[];
|
chips: AppliedChipEntry[];
|
||||||
resultCount: number;
|
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorAppliedFiltersBar({
|
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
|
||||||
chips,
|
|
||||||
resultCount,
|
|
||||||
onClearAll,
|
|
||||||
}: VendorAppliedFiltersBarProps) {
|
|
||||||
if (chips.length === 0) return null;
|
if (chips.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
|
||||||
<Text variant="caption" tone="muted">
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
|
Applied:
|
||||||
</Text>
|
</Typography>
|
||||||
{chips.map((chip) => (
|
{chips.map((chip) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={chip.key}
|
key={chip.key}
|
||||||
|
|
@ -139,31 +146,23 @@ export function VendorsListFooter({
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: { xs: "stretch", sm: "center" },
|
alignItems: { xs: "stretch", sm: "center" },
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
minHeight: 33.5,
|
|
||||||
mx: -2,
|
|
||||||
px: 2,
|
|
||||||
backgroundColor: "var(--color-bg-surface)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
{totalCount} {isActive ? "active" : "inactive"}{" "}
|
||||||
{totalCount === 1 ? "technician" : "technicians"}
|
{totalCount === 1 ? "vendor entry" : "vendor entries"}
|
||||||
{isFiltered && " · filtered"}
|
{isFiltered && " · filtered"}
|
||||||
</Text>
|
{` · ${activeCount} active · ${inactiveCount} inactive`}
|
||||||
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
|
</Typography>
|
||||||
{activeCount} active · {inactiveCount} inactive
|
<TablePagination
|
||||||
</Text>
|
component="div"
|
||||||
{totalCount > pageSize && (
|
count={totalCount}
|
||||||
<TablePagination
|
page={page}
|
||||||
component="div"
|
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
||||||
count={totalCount}
|
rowsPerPage={pageSize}
|
||||||
page={page}
|
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
rowsPerPage={pageSize}
|
/>
|
||||||
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
|
||||||
rowsPerPageOptions={[24, 48, 96]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { type KeyboardEvent, type MouseEvent } from "react";
|
import { type KeyboardEvent, type MouseEvent } from "react";
|
||||||
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
|
import { useNavigate } from "react-router";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
|
@ -17,8 +18,8 @@ import {
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
|
|
||||||
const TABLE_COLUMNS = [
|
const TABLE_COLUMNS = [
|
||||||
|
|
@ -29,6 +30,9 @@ const TABLE_COLUMNS = [
|
||||||
"Phone",
|
"Phone",
|
||||||
"Email",
|
"Email",
|
||||||
"Total Jobs",
|
"Total Jobs",
|
||||||
|
"Status",
|
||||||
|
"View",
|
||||||
|
"Edit",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
|
|
@ -50,34 +54,19 @@ function stopPropagation(event: MouseEvent): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function VendorStatus({ isActive }: { isActive: boolean }) {
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", pl: 1 }}>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "9999px",
|
|
||||||
backgroundColor: isActive ? "success.main" : "text.secondary",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VendorTableRowProps {
|
interface VendorTableRowProps {
|
||||||
row: VendorListItem;
|
row: VendorListItem;
|
||||||
onOpenDetail: (row: VendorListItem) => void;
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
onOpenEdit: (row: VendorListItem) => void;
|
onOpenDeactivation: (row: VendorListItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
|
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const trades = splitTrades(row.tradeSpecialties);
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
const primaryTrade = trades[0] ?? "—";
|
const primaryTrade = trades[0] ?? "—";
|
||||||
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(", ") || "—";
|
||||||
|
const hasVendorId = row.id != null;
|
||||||
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
@ -99,62 +88,37 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
|
||||||
{row.companyName || "—"}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
backgroundColor: "var(--color-bg-muted)",
|
fontSize: 12,
|
||||||
color: "var(--color-primary)",
|
backgroundColor: "var(--accent, #1976d2)",
|
||||||
fontSize: 10.5,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getInitials(row.contactName)}
|
{getInitials(row.contactName)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
<Typography variant="body2">{row.contactName || "—"}</Typography>
|
||||||
{row.contactName || "—"}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
|
<TableCell>{locationText}</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
<TableCell>
|
||||||
{primaryTrade === "—" ? (
|
{primaryTrade === "—" ? (
|
||||||
"—"
|
"—"
|
||||||
) : (
|
) : (
|
||||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
|
||||||
<span>{primaryTrade}</span>
|
<span>{primaryTrade}</span>
|
||||||
{extraTrades > 0 && (
|
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
borderRadius: "9999px",
|
|
||||||
backgroundColor: "var(--color-bg-muted)",
|
|
||||||
px: 0.75,
|
|
||||||
py: 0.25,
|
|
||||||
fontSize: 10.5,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+{extraTrades}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
<TableCell>
|
||||||
{row.phone ? (
|
{row.phone ? (
|
||||||
<Link
|
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
|
||||||
href={`tel:${row.phone}`}
|
|
||||||
underline="hover"
|
|
||||||
color="inherit"
|
|
||||||
onClick={stopPropagation}
|
|
||||||
>
|
|
||||||
{row.phone}
|
{row.phone}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -163,53 +127,71 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{row.email ? (
|
{row.email ? (
|
||||||
<Link
|
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
|
||||||
href={`mailto:${row.email}`}
|
|
||||||
underline="hover"
|
|
||||||
sx={{ color: "#6b7a96" }}
|
|
||||||
onClick={stopPropagation}
|
|
||||||
>
|
|
||||||
{row.email}
|
{row.email}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
<TableCell>{row.totalJobs ?? 0}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<VendorStatus isActive={row.isActive} />
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={row.isActive ? "Active" : "Inactive"}
|
||||||
|
color={row.isActive ? "success" : "default"}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="center">
|
||||||
|
<Tooltip title="View details">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`View vendor ${row.companyName}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenDetail(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisibilityOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center">
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
spacing={0.5}
|
spacing={0.5}
|
||||||
className="vendor-row-actions"
|
sx={{ alignItems: "center", justifyContent: "center" }}
|
||||||
sx={{ alignItems: "center", justifyContent: "flex-end" }}
|
|
||||||
>
|
>
|
||||||
<Tooltip title="View details">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`View vendor ${row.companyName}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onOpenDetail(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VisibilityOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={`Edit vendor ${row.companyName}`}
|
aria-label={`Edit vendor ${row.companyName}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onOpenEdit(row);
|
navigate(
|
||||||
|
row.id != null
|
||||||
|
? `/vendors/edit/${row.id}`
|
||||||
|
: `/vendors/company/${row.companyId ?? ""}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="small" />
|
<EditOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{hasVendorId && row.isActive && (
|
||||||
|
<Tooltip title="Deactivate">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Deactivate vendor"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenDeactivation(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -218,118 +200,50 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
|
|
||||||
interface VendorsTableProps {
|
interface VendorsTableProps {
|
||||||
rows: VendorListItem[];
|
rows: VendorListItem[];
|
||||||
isActive: boolean;
|
|
||||||
isFiltered: boolean;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
showError: boolean;
|
showError: boolean;
|
||||||
onOpenDetail: (row: VendorListItem) => void;
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
onOpenEdit: (row: VendorListItem) => void;
|
onOpenDeactivation: (row: VendorListItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorsTable({
|
export function VendorsTable({
|
||||||
rows,
|
rows,
|
||||||
isActive,
|
|
||||||
isFiltered,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
showError,
|
showError,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
onOpenEdit,
|
onOpenDeactivation,
|
||||||
}: VendorsTableProps) {
|
}: VendorsTableProps) {
|
||||||
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
const showEmptyRow = !isLoading && !showError && rows.length === 0;
|
||||||
const columnCount = TABLE_COLUMNS.length + 2;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer
|
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
|
||||||
component={Paper}
|
<Table size="small" sx={{ minWidth: 960 }}>
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
mt: 2,
|
|
||||||
mb: 2,
|
|
||||||
flex: "1 1 auto",
|
|
||||||
overflow: "auto",
|
|
||||||
borderRadius: 1.5,
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
stickyHeader
|
|
||||||
sx={{
|
|
||||||
minWidth: 1550,
|
|
||||||
tableLayout: "fixed",
|
|
||||||
"& .MuiTableCell-head": {
|
|
||||||
backgroundColor: "var(--color-bg-muted)",
|
|
||||||
color: "var(--color-foreground)",
|
|
||||||
fontSize: 10.5,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.06em",
|
|
||||||
lineHeight: "24px",
|
|
||||||
px: 1.75,
|
|
||||||
py: 1.375,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
},
|
|
||||||
"& .MuiTableCell-body": {
|
|
||||||
fontSize: 13,
|
|
||||||
px: 1.5,
|
|
||||||
py: 1,
|
|
||||||
height: 52,
|
|
||||||
lineHeight: 1.43,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
},
|
|
||||||
"& .vendor-row-actions": {
|
|
||||||
opacity: { xs: 1, md: 0 },
|
|
||||||
transition: "opacity 120ms ease",
|
|
||||||
},
|
|
||||||
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<colgroup>
|
|
||||||
<col style={{ width: 196.34375 }} />
|
|
||||||
<col style={{ width: 207.25 }} />
|
|
||||||
<col style={{ width: 152.703125 }} />
|
|
||||||
<col style={{ width: 218.15625 }} />
|
|
||||||
<col style={{ width: 152.703125 }} />
|
|
||||||
<col style={{ width: 251.90625 }} />
|
|
||||||
<col style={{ width: 109.078125 }} />
|
|
||||||
<col style={{ width: 119.984375 }} />
|
|
||||||
<col style={{ width: 141.875 }} />
|
|
||||||
</colgroup>
|
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{TABLE_COLUMNS.map((column) => (
|
{TABLE_COLUMNS.map((column) => (
|
||||||
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
|
<TableCell
|
||||||
|
key={column}
|
||||||
|
align={column === "View" || column === "Edit" ? "center" : "left"}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
{column}
|
{column}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell align="right" aria-label="Vendor actions" />
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : showEmptyRow ? (
|
) : showEmptyRow ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columnCount} align="center" className="py-10">
|
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
|
||||||
<Stack spacing={1} sx={{ alignItems: "center" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
|
No vendors match the current filters.
|
||||||
<Text variant="description" className="font-semibold">
|
</Typography>
|
||||||
{`No ${isActive ? "active" : "inactive"} technicians found`}
|
|
||||||
</Text>
|
|
||||||
<Text variant="caption" tone="muted">
|
|
||||||
{isFiltered
|
|
||||||
? "Try a different search, or add a new vendor."
|
|
||||||
: "Add a vendor to get started."}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -338,7 +252,7 @@ export function VendorsTable({
|
||||||
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
|
||||||
row={row}
|
row={row}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
onOpenEdit={onOpenEdit}
|
onOpenDeactivation={onOpenDeactivation}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
125
src/app/(protected)/vendors/index.tsx
vendored
125
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -23,91 +23,16 @@ interface AppliedChip {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusTabLabel(label: string, count: number) {
|
|
||||||
return (
|
|
||||||
<Box className="flex items-center gap-2">
|
|
||||||
<Box component="span">{label}</Box>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
|
|
||||||
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VendorStatusTabs({
|
|
||||||
isActive,
|
|
||||||
activeCount,
|
|
||||||
inactiveCount,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
isActive: boolean;
|
|
||||||
activeCount: number;
|
|
||||||
inactiveCount: number;
|
|
||||||
onChange: (isActive: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
value={isActive ? "active" : "inactive"}
|
|
||||||
onChange={(_event, value: "active" | "inactive") => onChange(value === "active")}
|
|
||||||
aria-label="Vendor status"
|
|
||||||
sx={{
|
|
||||||
minHeight: 36,
|
|
||||||
flexShrink: 0,
|
|
||||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
|
||||||
backgroundColor: "var(--color-bg-surface)",
|
|
||||||
mx: -2,
|
|
||||||
px: 1.5,
|
|
||||||
"& .MuiTab-root": {
|
|
||||||
minHeight: 36,
|
|
||||||
px: 2,
|
|
||||||
py: 0,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: "none",
|
|
||||||
},
|
|
||||||
"& .MuiTab-root:first-of-type": { width: 102.84375 },
|
|
||||||
"& .Mui-selected": { fontWeight: 600 },
|
|
||||||
"& .MuiTabs-indicator": { height: 2 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
value="active"
|
|
||||||
label={statusTabLabel("Active", activeCount)}
|
|
||||||
aria-label={`Active (${activeCount})`}
|
|
||||||
/>
|
|
||||||
<Tab
|
|
||||||
value="inactive"
|
|
||||||
label={statusTabLabel("Inactive", inactiveCount)}
|
|
||||||
aria-label={`Inactive (${inactiveCount})`}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeChips(
|
function describeChips(
|
||||||
filters: VendorFilters,
|
filters: VendorFilters,
|
||||||
jobBucketLabels: Map<string, string>,
|
jobBucketLabels: Map<string, string>,
|
||||||
): AppliedChip[] {
|
): AppliedChip[] {
|
||||||
const chips: AppliedChip[] = [];
|
const chips: AppliedChip[] = [];
|
||||||
filters.companies.forEach((value) =>
|
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
|
||||||
chips.push({ category: "companies", value, label: `Company: ${value}` }),
|
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
|
||||||
);
|
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
|
||||||
filters.trades.forEach((value) =>
|
|
||||||
chips.push({ category: "trades", value, label: `Trade: ${value}` }),
|
|
||||||
);
|
|
||||||
filters.locations.forEach((value) =>
|
|
||||||
chips.push({ category: "locations", value, label: `Location: ${value}` }),
|
|
||||||
);
|
|
||||||
filters.jobBuckets.forEach((value) =>
|
filters.jobBuckets.forEach((value) =>
|
||||||
chips.push({
|
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
|
||||||
category: "jobBuckets",
|
|
||||||
value,
|
|
||||||
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return chips;
|
return chips;
|
||||||
}
|
}
|
||||||
|
|
@ -117,12 +42,7 @@ 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 [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
|
||||||
const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
|
const deactivation = useVendorDeactivation();
|
||||||
const closeDetail = () => {
|
|
||||||
setDetailTarget(null);
|
|
||||||
setDetailStartsInEdit(false);
|
|
||||||
};
|
|
||||||
const deactivation = useVendorDeactivation(closeDetail);
|
|
||||||
|
|
||||||
const { data: facets } = useVendorFacets(filters.isActive);
|
const { data: facets } = useVendorFacets(filters.isActive);
|
||||||
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
|
||||||
|
|
@ -141,7 +61,7 @@ export default function VendorsListPage() {
|
||||||
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<VendorsListToolbar
|
<VendorsListToolbar
|
||||||
globalCount={activeCount + inactiveCount}
|
globalCount={activeCount + inactiveCount}
|
||||||
activeCount={activeCount}
|
activeCount={activeCount}
|
||||||
|
|
@ -154,15 +74,18 @@ export default function VendorsListPage() {
|
||||||
onAddVendor={() => setCreateOpen(true)}
|
onAddVendor={() => setCreateOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorStatusTabs
|
<Tabs
|
||||||
isActive={filters.isActive}
|
value={filters.isActive ? "active" : "inactive"}
|
||||||
activeCount={activeCount}
|
onChange={(_event, value: "active" | "inactive") =>
|
||||||
inactiveCount={inactiveCount}
|
filters.setIsActiveWithReset(value === "active")
|
||||||
onChange={filters.setIsActiveWithReset}
|
}
|
||||||
/>
|
aria-label="Vendor status"
|
||||||
|
>
|
||||||
|
<Tab value="active" label={`Active (${activeCount})`} />
|
||||||
|
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<VendorAppliedFiltersBar
|
<VendorAppliedFiltersBar
|
||||||
resultCount={totalCount}
|
|
||||||
chips={appliedChips.map((chip) => ({
|
chips={appliedChips.map((chip) => ({
|
||||||
key: `${chip.category}-${chip.value}`,
|
key: `${chip.category}-${chip.value}`,
|
||||||
label: chip.label,
|
label: chip.label,
|
||||||
|
|
@ -179,18 +102,10 @@ export default function VendorsListPage() {
|
||||||
|
|
||||||
<VendorsTable
|
<VendorsTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
isActive={filters.isActive}
|
|
||||||
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showError={Boolean(error)}
|
showError={Boolean(error)}
|
||||||
onOpenDetail={(row) => {
|
onOpenDetail={setDetailTarget}
|
||||||
setDetailStartsInEdit(false);
|
onOpenDeactivation={deactivation.open}
|
||||||
setDetailTarget(row);
|
|
||||||
}}
|
|
||||||
onOpenEdit={(row) => {
|
|
||||||
setDetailStartsInEdit(true);
|
|
||||||
setDetailTarget(row);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorsListFooter
|
<VendorsListFooter
|
||||||
|
|
@ -218,9 +133,7 @@ export default function VendorsListPage() {
|
||||||
<VendorDetailDrawer
|
<VendorDetailDrawer
|
||||||
open={Boolean(detailTarget)}
|
open={Boolean(detailTarget)}
|
||||||
vendor={detailTarget}
|
vendor={detailTarget}
|
||||||
startInEdit={detailStartsInEdit}
|
onClose={() => setDetailTarget(null)}
|
||||||
onRequestDeactivation={deactivation.open}
|
|
||||||
onClose={closeDetail}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorDeactivationDialog
|
<VendorDeactivationDialog
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export function buildInlineDraftCreateInput(
|
||||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||||
woNumber: draft.woNumber,
|
woNumber: draft.woNumber,
|
||||||
type: draft.type,
|
type: draft.type,
|
||||||
|
severity: draft.severity,
|
||||||
assigneeId: draft.dispatcherId,
|
assigneeId: draft.dispatcherId,
|
||||||
assigneeName: assignee?.name ?? "",
|
assigneeName: assignee?.name ?? "",
|
||||||
scheduledOn: draft.scheduledOn,
|
scheduledOn: draft.scheduledOn,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Tooltip } from "@mui/material";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SEVERITY_LEVELS,
|
||||||
|
SEVERITY_TOKEN_COLOR,
|
||||||
|
type WorkOrderSeverity,
|
||||||
|
} from "@/domain/work-orders/types/work-order-severity";
|
||||||
|
|
||||||
|
type SeverityBadgeProps = {
|
||||||
|
severity: WorkOrderSeverity;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SeverityBadge({ severity, onClick }: SeverityBadgeProps) {
|
||||||
|
const lvl = SEVERITY_LEVELS[severity];
|
||||||
|
const color = SEVERITY_TOKEN_COLOR[lvl.token];
|
||||||
|
const title = `${lvl.description} · ${lvl.deadline} · After hours: ${lvl.afterHours}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={title} enterDelay={400}>
|
||||||
|
<span
|
||||||
|
onClick={onClick}
|
||||||
|
role={onClick ? "button" : undefined}
|
||||||
|
className="inline-flex shrink-0 items-center whitespace-nowrap rounded leading-none"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
padding: "1px 5px",
|
||||||
|
marginLeft: 4,
|
||||||
|
cursor: onClick ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lvl.label}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
SEVERITY_LEVELS,
|
||||||
|
SEVERITY_TOKEN_COLOR,
|
||||||
|
WORK_ORDER_SEVERITIES,
|
||||||
|
type WorkOrderSeverity,
|
||||||
|
} from "@/domain/work-orders/types/work-order-severity";
|
||||||
|
|
||||||
|
type SeverityOptionsListProps = {
|
||||||
|
value: WorkOrderSeverity | null;
|
||||||
|
onSelect: (severity: WorkOrderSeverity) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SeverityOptionsList({ value, onSelect }: SeverityOptionsListProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{WORK_ORDER_SEVERITIES.map((n) => {
|
||||||
|
const lvl = SEVERITY_LEVELS[n];
|
||||||
|
const color = SEVERITY_TOKEN_COLOR[lvl.token];
|
||||||
|
const selected = value === n;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(n)}
|
||||||
|
className="flex items-start gap-2 rounded-md text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: `1px solid ${selected ? color : "var(--color-border)"}`,
|
||||||
|
background: selected ? "var(--color-bg-muted)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "1px 5px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lvl.label}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-primary)" }}>
|
||||||
|
{lvl.description}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{lvl.deadline}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeverityFieldProps = {
|
||||||
|
value: WorkOrderSeverity | null;
|
||||||
|
onSelect: (severity: WorkOrderSeverity) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SeverityField({ value, onSelect }: SeverityFieldProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
||||||
|
>
|
||||||
|
Severity (from APM)
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 11, color: "var(--color-text-muted)", margin: "0 0 8px" }}
|
||||||
|
>
|
||||||
|
Transcribe the severity already assigned on the client's ticket — this is not a
|
||||||
|
judgment call.
|
||||||
|
</Text>
|
||||||
|
<SeverityOptionsList value={value} onSelect={onSelect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,49 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
|
|
||||||
|
import {
|
||||||
|
typeNeedsSeverity,
|
||||||
|
type WorkOrderSeverity,
|
||||||
|
} from "@/domain/work-orders/types/work-order-severity";
|
||||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import { HoverChevron } from "./hover-chevron";
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
import { SeverityBadge } from "./severity-badge";
|
||||||
|
import { TypeSeverityModal } from "./type-severity-modal";
|
||||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||||
|
|
||||||
type TypeCellProps = {
|
type TypeCellProps = {
|
||||||
value: WOType | "";
|
value: WOType | "";
|
||||||
|
severity?: WorkOrderSeverity | null;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||||
onSave: (v: WOType) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||||
|
const [resetSeverity, setResetSeverity] = useState(false);
|
||||||
|
|
||||||
|
function pick(t: WOType) {
|
||||||
|
setAnchorEl(null);
|
||||||
|
const needsSeverity = typeNeedsSeverity(t);
|
||||||
|
const switchingSeverityType =
|
||||||
|
needsSeverity && value !== "" && typeNeedsSeverity(value) && t !== value;
|
||||||
|
|
||||||
|
if (needsSeverity && (severity == null || switchingSeverityType)) {
|
||||||
|
setModalSeed(t);
|
||||||
|
setResetSeverity(Boolean(switchingSeverityType && severity != null));
|
||||||
|
setModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(t, needsSeverity ? severity : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
|
@ -37,7 +60,17 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||||
)}
|
)}
|
||||||
|
{Boolean(showSeverityBadge && severity != null) && (
|
||||||
|
<SeverityBadge
|
||||||
|
severity={severity!}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setModalSeed(value);
|
||||||
|
setResetSeverity(false);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => pick(t)}
|
||||||
onSave(t);
|
|
||||||
|
|
||||||
setAnchorEl(null);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||||
>
|
>
|
||||||
<WoTableTypeLabel type={t} />
|
<WoTableTypeLabel type={t} />
|
||||||
|
|
||||||
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
|
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
<TypeSeverityModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
type={modalSeed}
|
||||||
|
severity={resetSeverity ? null : severity}
|
||||||
|
onSave={onSave}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
typeNeedsSeverity,
|
||||||
|
type WorkOrderSeverity,
|
||||||
|
} from "@/domain/work-orders/types/work-order-severity";
|
||||||
|
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
import { SeverityField } from "./severity-options-list";
|
||||||
|
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||||
|
|
||||||
|
type TypeSeverityModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
type: WOType | "";
|
||||||
|
severity: WorkOrderSeverity | null;
|
||||||
|
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TypeSeverityModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
onSave,
|
||||||
|
}: TypeSeverityModalProps) {
|
||||||
|
const [draftType, setDraftType] = useState<WOType | "">(type);
|
||||||
|
const [draftSeverity, setDraftSeverity] = useState<WorkOrderSeverity | null>(severity);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraftType(type);
|
||||||
|
setDraftSeverity(severity);
|
||||||
|
}
|
||||||
|
}, [open, type, severity]);
|
||||||
|
|
||||||
|
const needsSeverity = typeNeedsSeverity(draftType);
|
||||||
|
|
||||||
|
function pickType(t: WOType) {
|
||||||
|
if (typeNeedsSeverity(t)) {
|
||||||
|
if (t !== draftType) {
|
||||||
|
setDraftSeverity(null);
|
||||||
|
}
|
||||||
|
setDraftType(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(t, null);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!draftType || (needsSeverity && draftSeverity == null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(draftType, needsSeverity ? draftSeverity : null);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canConfirm = Boolean(draftType) && (!needsSeverity || draftSeverity != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: { borderRadius: 2, background: "var(--color-bg-surface)" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Type & severity
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{WO_TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickType(t)}
|
||||||
|
className="rounded-full transition-opacity hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
border: `1px solid ${
|
||||||
|
draftType === t ? "var(--color-primary)" : "var(--color-border)"
|
||||||
|
}`,
|
||||||
|
background: draftType === t ? "var(--color-primary-tint)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WoTableTypeLabel type={t} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Boolean(needsSeverity) && (
|
||||||
|
<SeverityField value={draftSeverity} onSelect={setDraftSeverity} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
|
||||||
|
<Button variant="text" size="small" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
sx={
|
||||||
|
canConfirm
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -77,7 +77,11 @@ export function WoTableInlineRowCells({
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
<TypeCell
|
||||||
|
value={draft.type}
|
||||||
|
severity={draft.severity}
|
||||||
|
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,12 @@ export function WoTableRowCells({
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<TypeCell value={row.type} q={search} onSave={(t) => onPatchRow({ type: t })} />
|
<TypeCell
|
||||||
|
value={row.type}
|
||||||
|
severity={row.severity}
|
||||||
|
q={search}
|
||||||
|
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="group/cell"
|
className="group/cell"
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,14 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Dialog } from "@mui/material";
|
import { Dialog } from "@mui/material";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
|
||||||
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
|
||||||
import {
|
|
||||||
getMockWoWizardLocationDetail,
|
|
||||||
MOCK_WO_WIZARD_LOCATIONS,
|
|
||||||
MOCK_WO_WIZARD_SERVICES,
|
|
||||||
MOCK_WO_WIZARD_USERS,
|
|
||||||
MOCK_WO_WIZARD_VENDORS,
|
|
||||||
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import {
|
import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
EMPTY_WIZARD_DRAFT,
|
|
||||||
WIZARD_STEPS,
|
|
||||||
type WorkOrderWizardDraft,
|
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
|
||||||
import {
|
|
||||||
useWorkOrderLocations,
|
|
||||||
useWorkOrderVendors,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
|
||||||
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
|
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
|
||||||
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
|
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
|
||||||
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
|
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
|
||||||
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||||
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||||
|
import { useNewWoWizardState } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state";
|
||||||
|
|
||||||
type NewWoWizardProps = {
|
type NewWoWizardProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -76,80 +57,7 @@ function WizardStepHeader({ step }: { step: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
|
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
|
||||||
const mockFallback = isWoWizardMockEnabled();
|
const wizard = useNewWoWizardState({ open, users, onOpenChange });
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
|
||||||
...EMPTY_WIZARD_DRAFT,
|
|
||||||
status: "Incomplete",
|
|
||||||
});
|
|
||||||
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
|
||||||
|
|
||||||
const { data: apiLocations = [] } = useWorkOrderLocations();
|
|
||||||
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
|
|
||||||
const isMockLocation = draft.locationId.startsWith("mock-");
|
|
||||||
const { data: apiLocationDetail } = useLocationDetail(
|
|
||||||
isMockLocation ? undefined : draft.locationId || undefined,
|
|
||||||
);
|
|
||||||
const mockLocationDetail = isMockLocation
|
|
||||||
? getMockWoWizardLocationDetail(draft.locationId)
|
|
||||||
: undefined;
|
|
||||||
const locationDetail = mockLocationDetail ?? apiLocationDetail;
|
|
||||||
const { data: apiVendors = [] } = useWorkOrderVendors(
|
|
||||||
undefined,
|
|
||||||
isMockLocation ? undefined : locationDetail?.zipCode,
|
|
||||||
);
|
|
||||||
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
|
||||||
|
|
||||||
const wizardUsers = useMemo(() => {
|
|
||||||
if (users.length > 0) {
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
return mockFallback ? MOCK_WO_WIZARD_USERS : [];
|
|
||||||
}, [mockFallback, users]);
|
|
||||||
const locations =
|
|
||||||
apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
|
|
||||||
const problems =
|
|
||||||
apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
|
|
||||||
const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setStep(1);
|
|
||||||
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
|
||||||
setPocAutoFilledFor("");
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!draft.locationId || !locationDetail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pocAutoFilledFor === draft.locationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
pocName: locationDetail.contact ?? current.pocName,
|
|
||||||
pocPhone: locationDetail.phone ?? current.pocPhone,
|
|
||||||
}));
|
|
||||||
setPocAutoFilledFor(draft.locationId);
|
|
||||||
}, [draft.locationId, locationDetail, pocAutoFilledFor]);
|
|
||||||
|
|
||||||
const setField = <K extends keyof WorkOrderWizardDraft>(
|
|
||||||
key: K,
|
|
||||||
value: WorkOrderWizardDraft[K],
|
|
||||||
) => {
|
|
||||||
setDraft((current) => ({ ...current, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
|
||||||
setDraft((current) => ({ ...current, ...patch }));
|
|
||||||
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
|
|
||||||
setPocAutoFilledFor("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = () => createMutation.mutate(draft);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -173,53 +81,62 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
|
||||||
<span className="sr-only">Guided setup for creating a new work order</span>
|
<span className="sr-only">Guided setup for creating a new work order</span>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<WizardSidebar step={step} draft={draft} />
|
<WizardSidebar step={wizard.step} draft={wizard.draft} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
style={{ padding: 32, background: "var(--color-bg-surface)" }}
|
style={{ padding: 32, background: "var(--color-bg-surface)" }}
|
||||||
>
|
>
|
||||||
<WizardStepHeader step={step} />
|
<WizardStepHeader step={wizard.step} />
|
||||||
|
|
||||||
{step === 1 && (
|
{wizard.step === 1 && (
|
||||||
<WizardStepTypeSchedule
|
<WizardStepTypeSchedule
|
||||||
draft={draft}
|
draft={wizard.draft}
|
||||||
users={wizardUsers}
|
users={wizard.wizardUsers}
|
||||||
onUpdate={setField}
|
showErrors={wizard.showErrors}
|
||||||
onPatch={patchDraft}
|
onUpdate={wizard.setField}
|
||||||
|
onPatch={wizard.patchDraft}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{wizard.step === 2 && (
|
||||||
<WizardStepLocationService
|
<WizardStepLocationService
|
||||||
draft={draft}
|
draft={wizard.draft}
|
||||||
locations={locations}
|
locations={wizard.locations}
|
||||||
locationDetail={locationDetail}
|
locationDetail={wizard.locationDetail}
|
||||||
services={problems.map((p) => ({ id: p.id, value: p.value }))}
|
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
|
||||||
onUpdate={setField}
|
showErrors={wizard.showErrors}
|
||||||
onPatch={patchDraft}
|
onUpdate={wizard.setField}
|
||||||
|
onPatch={wizard.patchDraft}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && (
|
{wizard.step === 3 && (
|
||||||
<WizardStepVendorTime
|
<WizardStepVendorTime
|
||||||
draft={draft}
|
draft={wizard.draft}
|
||||||
vendors={vendors}
|
vendors={wizard.vendors}
|
||||||
onUpdate={setField}
|
onUpdate={wizard.setField}
|
||||||
onPatch={patchDraft}
|
onPatch={wizard.patchDraft}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WizardFooter
|
<WizardFooter
|
||||||
step={step}
|
step={wizard.step}
|
||||||
draft={draft}
|
draft={wizard.draft}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
onBack={() => setStep((s) => s - 1)}
|
onBack={() => {
|
||||||
onContinue={() => setStep((s) => s + 1)}
|
wizard.setShowErrors(false);
|
||||||
onCreate={handleCreate}
|
wizard.setStep((s) => s - 1);
|
||||||
isCreating={createMutation.isPending}
|
}}
|
||||||
|
onContinue={() => {
|
||||||
|
wizard.setShowErrors(false);
|
||||||
|
wizard.setStep((s) => s + 1);
|
||||||
|
}}
|
||||||
|
onAttemptContinue={() => wizard.setShowErrors(true)}
|
||||||
|
onCreate={wizard.handleCreate}
|
||||||
|
isCreating={wizard.isCreating}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
|
||||||
|
import {
|
||||||
|
getMockWoWizardLocationDetail,
|
||||||
|
MOCK_WO_WIZARD_LOCATIONS,
|
||||||
|
MOCK_WO_WIZARD_SERVICES,
|
||||||
|
MOCK_WO_WIZARD_USERS,
|
||||||
|
MOCK_WO_WIZARD_VENDORS,
|
||||||
|
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import {
|
||||||
|
EMPTY_WIZARD_DRAFT,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||||
|
|
||||||
|
type UseNewWoWizardStateArgs = {
|
||||||
|
open: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useNewWoWizardState({ open, users, onOpenChange }: UseNewWoWizardStateArgs) {
|
||||||
|
const mockFallback = isWoWizardMockEnabled();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
status: "Incomplete",
|
||||||
|
});
|
||||||
|
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
||||||
|
|
||||||
|
const { data: apiLocations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
|
const isMockLocation = draft.locationId.startsWith("mock-");
|
||||||
|
const { data: apiLocationDetail } = useLocationDetail(
|
||||||
|
isMockLocation ? undefined : draft.locationId || undefined,
|
||||||
|
);
|
||||||
|
const mockLocationDetail = isMockLocation
|
||||||
|
? getMockWoWizardLocationDetail(draft.locationId)
|
||||||
|
: undefined;
|
||||||
|
const locationDetail = mockLocationDetail ?? apiLocationDetail;
|
||||||
|
const { data: apiVendors = [] } = useWorkOrderVendors(
|
||||||
|
undefined,
|
||||||
|
isMockLocation ? undefined : locationDetail?.zipCode,
|
||||||
|
);
|
||||||
|
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
|
||||||
|
|
||||||
|
const wizardUsers = useMemo(() => {
|
||||||
|
if (users.length > 0) {
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
return mockFallback ? MOCK_WO_WIZARD_USERS : [];
|
||||||
|
}, [mockFallback, users]);
|
||||||
|
const locations =
|
||||||
|
apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
|
||||||
|
const problems =
|
||||||
|
apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
|
||||||
|
const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setStep(1);
|
||||||
|
setShowErrors(false);
|
||||||
|
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
|
||||||
|
setPocAutoFilledFor("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draft.locationId || !locationDetail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pocAutoFilledFor === draft.locationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
pocName: locationDetail.contact ?? current.pocName,
|
||||||
|
pocPhone: locationDetail.phone ?? current.pocPhone,
|
||||||
|
}));
|
||||||
|
setPocAutoFilledFor(draft.locationId);
|
||||||
|
}, [draft.locationId, locationDetail, pocAutoFilledFor]);
|
||||||
|
|
||||||
|
const setField = <K extends keyof WorkOrderWizardDraft>(
|
||||||
|
key: K,
|
||||||
|
value: WorkOrderWizardDraft[K],
|
||||||
|
) => {
|
||||||
|
setDraft((current) => ({ ...current, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
|
||||||
|
setDraft((current) => ({ ...current, ...patch }));
|
||||||
|
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
|
||||||
|
setPocAutoFilledFor("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => createMutation.mutate(draft);
|
||||||
|
|
||||||
|
return {
|
||||||
|
step,
|
||||||
|
setStep,
|
||||||
|
showErrors,
|
||||||
|
setShowErrors,
|
||||||
|
draft,
|
||||||
|
wizardUsers,
|
||||||
|
locations,
|
||||||
|
problems,
|
||||||
|
vendors,
|
||||||
|
locationDetail,
|
||||||
|
setField,
|
||||||
|
patchDraft,
|
||||||
|
handleCreate,
|
||||||
|
isCreating: createMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,10 @@ import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
WIZARD_STEPS,
|
WIZARD_STEPS,
|
||||||
canContinueWizardStep1,
|
canContinueWizardStep1,
|
||||||
|
canContinueWizardStep2,
|
||||||
canCreateWorkOrderFromWizard,
|
canCreateWorkOrderFromWizard,
|
||||||
isWizardStepValid,
|
isWizardStepValid,
|
||||||
|
isWizardWoNumberValid,
|
||||||
type WorkOrderWizardDraft,
|
type WorkOrderWizardDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
|
@ -15,9 +17,24 @@ type WizardFooterProps = {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
|
onAttemptContinue?: () => void;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
|
||||||
|
if (step === 1 && !canContinueWizardStep1(draft)) {
|
||||||
|
if (!isWizardWoNumberValid(draft.woNumber)) {
|
||||||
|
return "Enter an alphanumeric WO number, or leave blank for SH generation";
|
||||||
|
}
|
||||||
|
return "Select severity to continue";
|
||||||
|
}
|
||||||
|
if (step === 2 && !canContinueWizardStep2(draft)) {
|
||||||
|
if (!draft.locationId) return "Pick a site to continue";
|
||||||
|
return "Enter POC name and phone to continue";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function WizardFooter({
|
export function WizardFooter({
|
||||||
step,
|
step,
|
||||||
draft,
|
draft,
|
||||||
|
|
@ -25,11 +42,12 @@ export function WizardFooter({
|
||||||
onBack,
|
onBack,
|
||||||
onContinue,
|
onContinue,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onAttemptContinue,
|
||||||
isCreating,
|
isCreating,
|
||||||
}: WizardFooterProps) {
|
}: WizardFooterProps) {
|
||||||
const stepValid = isWizardStepValid(step, draft);
|
const stepValid = isWizardStepValid(step, draft);
|
||||||
const canCreate = canCreateWorkOrderFromWizard(draft);
|
const canCreate = canCreateWorkOrderFromWizard(draft);
|
||||||
const canContinue1 = canContinueWizardStep1(draft);
|
const hint = stepHint(step, draft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -60,20 +78,25 @@ export function WizardFooter({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-1 justify-center">
|
<div className="flex flex-1 justify-center">
|
||||||
{step === 1 && !canContinue1 && (
|
{Boolean(hint) && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1.5"
|
className="inline-flex items-center gap-1.5"
|
||||||
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
>
|
>
|
||||||
<Lightbulb size={13} /> Pick a type to continue
|
<Lightbulb size={13} /> {hint}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step < WIZARD_STEPS.length ? (
|
{step < WIZARD_STEPS.length ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={!stepValid}
|
onClick={() => {
|
||||||
onClick={onContinue}
|
if (!stepValid) {
|
||||||
|
onAttemptContinue?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onContinue();
|
||||||
|
}}
|
||||||
endIcon={<ChevronRight size={14} />}
|
endIcon={<ChevronRight size={14} />}
|
||||||
sx={{
|
sx={{
|
||||||
background: "var(--color-primary)",
|
background: "var(--color-primary)",
|
||||||
|
|
@ -81,17 +104,23 @@ export function WizardFooter({
|
||||||
height: 36,
|
height: 36,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}),
|
...(!stepValid ? { opacity: 0.7 } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
disabled={!canCreate || isCreating}
|
disabled={isCreating}
|
||||||
loading={isCreating}
|
loading={isCreating}
|
||||||
loadingText="Creating…"
|
loadingText="Creating…"
|
||||||
onClick={onCreate}
|
onClick={() => {
|
||||||
|
if (!canCreate) {
|
||||||
|
onAttemptContinue?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreate();
|
||||||
|
}}
|
||||||
startIcon={!isCreating ? <Check size={14} /> : undefined}
|
startIcon={!isCreating ? <Check size={14} /> : undefined}
|
||||||
sx={{
|
sx={{
|
||||||
background: "var(--color-primary)",
|
background: "var(--color-primary)",
|
||||||
|
|
@ -99,7 +128,7 @@ export function WizardFooter({
|
||||||
height: 36,
|
height: 36,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}),
|
...(!canCreate ? { opacity: 0.7 } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create work order
|
Create work order
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { Detail, WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
|
||||||
|
type WizardLocationDetailPocProps = {
|
||||||
|
draft: WorkOrderWizardDraft;
|
||||||
|
locationDetail: Location;
|
||||||
|
showErrors?: boolean;
|
||||||
|
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildMapsUrl(location: Location): string {
|
||||||
|
const parts = [location.address, location.city, location.state, location.zipCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardLocationDetailPoc({
|
||||||
|
draft,
|
||||||
|
locationDetail,
|
||||||
|
showErrors = false,
|
||||||
|
onUpdate,
|
||||||
|
}: WizardLocationDetailPocProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<Detail
|
||||||
|
icon={MapPin}
|
||||||
|
text={
|
||||||
|
[
|
||||||
|
locationDetail.address,
|
||||||
|
locationDetail.city,
|
||||||
|
locationDetail.state,
|
||||||
|
locationDetail.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Boolean(locationDetail.phone) && <Detail icon={Phone} text={locationDetail.phone ?? ""} />}
|
||||||
|
{Boolean(locationDetail.contactEmail) && (
|
||||||
|
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(locationDetail.address) && (
|
||||||
|
<a
|
||||||
|
href={buildMapsUrl(locationDetail)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<WizLabel required>Point of contact</WizLabel>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
|
||||||
|
>
|
||||||
|
Auto-filled from records — edit if outdated
|
||||||
|
</Text>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
value={draft.pocName}
|
||||||
|
onChange={(e) => onUpdate("pocName", e.target.value)}
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.pocPhone}
|
||||||
|
onChange={(e) => onUpdate("pocPhone", e.target.value)}
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Boolean(showErrors && (!draft.pocName.trim() || !draft.pocPhone.trim())) && (
|
||||||
|
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||||
|
POC name and phone are required.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={draft.pocNotes}
|
||||||
|
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
|
import { Info, MapPin } from "lucide-react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
import { WizardLocationDetailPoc } from "@/app/(protected)/workorders/_components/wizard/wizard-location-detail-poc";
|
||||||
DLG_INPUT_CLS,
|
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
DLG_INPUT_STYLE,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import {
|
|
||||||
Detail,
|
|
||||||
WizLabel,
|
|
||||||
WizSubLabel,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
|
|
||||||
type ServiceOption = { id: string | number; value: string };
|
type ServiceOption = { id: string | number; value: string };
|
||||||
|
|
||||||
|
|
@ -21,22 +15,17 @@ type WizardStepLocationServiceProps = {
|
||||||
locations: LocationOption[];
|
locations: LocationOption[];
|
||||||
locationDetail: Location | undefined;
|
locationDetail: Location | undefined;
|
||||||
services: ServiceOption[];
|
services: ServiceOption[];
|
||||||
|
showErrors?: boolean;
|
||||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMapsUrl(location: Location): string {
|
|
||||||
const parts = [location.address, location.city, location.state, location.zipCode]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WizardStepLocationService({
|
export function WizardStepLocationService({
|
||||||
draft,
|
draft,
|
||||||
locations,
|
locations,
|
||||||
locationDetail,
|
locationDetail,
|
||||||
services,
|
services,
|
||||||
|
showErrors = false,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WizardStepLocationServiceProps) {
|
}: WizardStepLocationServiceProps) {
|
||||||
|
|
@ -60,6 +49,7 @@ export function WizardStepLocationService({
|
||||||
onPatch({
|
onPatch({
|
||||||
locationId: id,
|
locationId: id,
|
||||||
locationName: loc?.name ?? "",
|
locationName: loc?.name ?? "",
|
||||||
|
siteCode: loc ? resolveSiteCode(loc) : "",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -76,77 +66,12 @@ export function WizardStepLocationService({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{locationDetail != null && draft.locationId !== "" && (
|
{locationDetail != null && draft.locationId !== "" && (
|
||||||
<>
|
<WizardLocationDetailPoc
|
||||||
<div
|
draft={draft}
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
locationDetail={locationDetail}
|
||||||
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
|
showErrors={showErrors}
|
||||||
>
|
onUpdate={onUpdate}
|
||||||
<Detail
|
/>
|
||||||
icon={MapPin}
|
|
||||||
text={
|
|
||||||
[
|
|
||||||
locationDetail.address,
|
|
||||||
locationDetail.city,
|
|
||||||
locationDetail.state,
|
|
||||||
locationDetail.zipCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ") || "—"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{Boolean(locationDetail.phone) && (
|
|
||||||
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(locationDetail.contactEmail) && (
|
|
||||||
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(locationDetail.address) && (
|
|
||||||
<a
|
|
||||||
href={buildMapsUrl(locationDetail)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} /> Open in Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<WizSubLabel>Point of contact</WizSubLabel>
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
variant="description"
|
|
||||||
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
|
|
||||||
>
|
|
||||||
Auto-filled from records — edit if outdated
|
|
||||||
</Text>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<input
|
|
||||||
value={draft.pocName}
|
|
||||||
onChange={(e) => onUpdate("pocName", e.target.value)}
|
|
||||||
placeholder="POC name"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={draft.pocPhone}
|
|
||||||
onChange={(e) => onUpdate("pocPhone", e.target.value)}
|
|
||||||
placeholder="POC phone"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={DLG_INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={draft.pocNotes}
|
|
||||||
onChange={(e) => onUpdate("pocNotes", e.target.value)}
|
|
||||||
placeholder="Notes…"
|
|
||||||
rows={2}
|
|
||||||
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import {
|
||||||
|
WO_TYPE_META,
|
||||||
|
WO_TYPES,
|
||||||
|
isWizardWoNumberValid,
|
||||||
|
type WOType,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import {
|
||||||
|
SEVERITY_LEVELS,
|
||||||
|
SEVERITY_TOKEN_COLOR,
|
||||||
|
WORK_ORDER_SEVERITIES,
|
||||||
|
typeNeedsSeverity,
|
||||||
|
type WorkOrderSeverity,
|
||||||
|
} from "@/domain/work-orders/types/work-order-severity";
|
||||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
import {
|
||||||
|
|
@ -111,9 +123,81 @@ function WoTypePicker({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SeverityPicker({
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
showError,
|
||||||
|
}: {
|
||||||
|
value: WorkOrderSeverity | null;
|
||||||
|
onSelect: (severity: WorkOrderSeverity) => void;
|
||||||
|
showError: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<WizLabel required>Severity (from APM)</WizLabel>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="description"
|
||||||
|
sx={{ fontSize: 11, color: "var(--muted-foreground)", margin: "0 0 8px" }}
|
||||||
|
>
|
||||||
|
Transcribe the severity already assigned on the client's ticket.
|
||||||
|
</Text>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{WORK_ORDER_SEVERITIES.map((n) => {
|
||||||
|
const lvl = SEVERITY_LEVELS[n];
|
||||||
|
const color = SEVERITY_TOKEN_COLOR[lvl.token];
|
||||||
|
const selected = value === n;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(n)}
|
||||||
|
className="flex items-start gap-2 rounded-md text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: `1px solid ${selected ? color : "var(--color-border)"}`,
|
||||||
|
background: selected ? "var(--color-bg-muted)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "1px 5px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lvl.label}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-primary)" }}>
|
||||||
|
{lvl.description}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{lvl.deadline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{Boolean(showError && value == null) && (
|
||||||
|
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||||
|
Severity is required for Emergency and Reactive work orders.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type WizardStepTypeScheduleProps = {
|
type WizardStepTypeScheduleProps = {
|
||||||
draft: WorkOrderWizardDraft;
|
draft: WorkOrderWizardDraft;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
showErrors?: boolean;
|
||||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -121,27 +205,54 @@ type WizardStepTypeScheduleProps = {
|
||||||
export function WizardStepTypeSchedule({
|
export function WizardStepTypeSchedule({
|
||||||
draft,
|
draft,
|
||||||
users,
|
users,
|
||||||
|
showErrors = false,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WizardStepTypeScheduleProps) {
|
}: WizardStepTypeScheduleProps) {
|
||||||
|
const needsSeverity = typeNeedsSeverity(draft.type);
|
||||||
|
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<WizLabel required>Type of WO</WizLabel>
|
<WizLabel optional>Type of WO</WizLabel>
|
||||||
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
|
<WoTypePicker
|
||||||
|
selected={draft.type}
|
||||||
|
onSelect={(t) => {
|
||||||
|
const nextSeverity = typeNeedsSeverity(t)
|
||||||
|
? draft.type !== t
|
||||||
|
? null
|
||||||
|
: draft.severity
|
||||||
|
: null;
|
||||||
|
onPatch({ type: t, severity: nextSeverity });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Boolean(needsSeverity) && (
|
||||||
|
<SeverityPicker
|
||||||
|
value={draft.severity}
|
||||||
|
showError={showErrors}
|
||||||
|
onSelect={(severity) => onUpdate("severity", severity)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<WizLabel optional>WO Number</WizLabel>
|
<WizLabel optional>WO Number</WizLabel>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={draft.woNumber}
|
value={draft.woNumber}
|
||||||
onChange={(e) => onUpdate("woNumber", e.target.value)}
|
onChange={(e) => onUpdate("woNumber", e.target.value)}
|
||||||
placeholder="e.g. 24818"
|
placeholder="Leave blank for SH provisional number"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
className={DLG_INPUT_CLS}
|
className={DLG_INPUT_CLS}
|
||||||
style={DLG_INPUT_STYLE}
|
style={DLG_INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
|
{Boolean(woNumberError) && (
|
||||||
|
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
||||||
|
WO number must be alphanumeric.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,10 @@ export function PageHeader({
|
||||||
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
|
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
|
||||||
eyebrow && "mt-1",
|
eyebrow && "mt-1",
|
||||||
)}
|
)}
|
||||||
sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }}
|
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
|
||||||
variant="description"
|
|
||||||
as="span"
|
|
||||||
tone="muted"
|
|
||||||
when={Boolean(subtitle)}
|
|
||||||
sx={{ mt: 0.75, fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -94,4 +94,8 @@ export function assignWizardServicePocFields(
|
||||||
if (vendorNotes) {
|
if (vendorNotes) {
|
||||||
payload.vendorNotes = vendorNotes;
|
payload.vendorNotes = vendorNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draft.severity != null) {
|
||||||
|
payload.severity = draft.severity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||||
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||||
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||||
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
||||||
|
{
|
||||||
|
key: "severity",
|
||||||
|
field: "severity",
|
||||||
|
toValue: (value) => (value == null ? "" : String(value)),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
|
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
|
||||||
import type {
|
import {
|
||||||
WOType,
|
effectiveWizardType,
|
||||||
WizardWOStatus,
|
type WOType,
|
||||||
WorkOrderWizardDraft,
|
type WizardWOStatus,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
||||||
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
@ -66,13 +67,14 @@ export function mapTypeToPriority(type: WOType | ""): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTitle(draft: WorkOrderWizardDraft): string {
|
function buildTitle(draft: WorkOrderWizardDraft): string {
|
||||||
|
const typeLabel = effectiveWizardType(draft.type);
|
||||||
if (draft.woNumber) {
|
if (draft.woNumber) {
|
||||||
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
|
return `${typeLabel} — #${draft.woNumber}`;
|
||||||
}
|
}
|
||||||
if (draft.locationName) {
|
if (draft.locationName) {
|
||||||
return `${draft.type || "Work Order"} — ${draft.locationName}`;
|
return `${typeLabel} — ${draft.locationName}`;
|
||||||
}
|
}
|
||||||
return draft.type || "Work Order";
|
return typeLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDescription(draft: WorkOrderWizardDraft): string {
|
function buildDescription(draft: WorkOrderWizardDraft): string {
|
||||||
|
|
@ -103,7 +105,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
||||||
workOrderNumber: draft.woNumber,
|
workOrderNumber: draft.woNumber,
|
||||||
title: buildTitle(draft),
|
title: buildTitle(draft),
|
||||||
locationId: draft.locationId,
|
locationId: draft.locationId,
|
||||||
priority: mapTypeToPriority(draft.type),
|
priority: mapTypeToPriority(effectiveWizardType(draft.type)),
|
||||||
status: mapWizardStatusToApi(draft.status),
|
status: mapWizardStatusToApi(draft.status),
|
||||||
createdDate: todayIso(),
|
createdDate: todayIso(),
|
||||||
dueDate: draft.dueDate,
|
dueDate: draft.dueDate,
|
||||||
|
|
@ -117,6 +119,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
|
||||||
trade: "",
|
trade: "",
|
||||||
subTrade: "",
|
subTrade: "",
|
||||||
vendorNTE: "",
|
vendorNTE: "",
|
||||||
source: draft.type,
|
source: effectiveWizardType(draft.type),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
|
||||||
export function mapWizardDraftToBoardCreatePayload(
|
export function mapWizardDraftToBoardCreatePayload(
|
||||||
draft: WorkOrderWizardDraft,
|
draft: WorkOrderWizardDraft,
|
||||||
): WorkOrderBoardCreatePayload {
|
): WorkOrderBoardCreatePayload {
|
||||||
const workOrderType = mapWizardTypeToApi(draft.type);
|
const workOrderType = mapWizardTypeToApi(draft.type || "PM");
|
||||||
|
|
||||||
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
|
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
CompletionDocStatus,
|
CompletionDocStatus,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { parseWorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
@ -234,6 +235,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
woNumber,
|
woNumber,
|
||||||
site: site || location.slice(0, 8),
|
site: site || location.slice(0, 8),
|
||||||
type: readWoType(item),
|
type: readWoType(item),
|
||||||
|
severity: parseWorkOrderSeverity(item.severity ?? item.Severity),
|
||||||
dispatcherId,
|
dispatcherId,
|
||||||
dispatcherName,
|
dispatcherName,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export interface WorkOrderBoardCreatePayload {
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
techPhone?: string;
|
techPhone?: string;
|
||||||
vendorNotes?: string;
|
vendorNotes?: string;
|
||||||
|
/** Severity 1–5 for Emergency/Reactive. */
|
||||||
|
severity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
/** Payload for PATCH /workorders/{id}/board (one field per request). */
|
||||||
|
|
@ -119,7 +121,8 @@ export type WorkOrderBoardEditableField =
|
||||||
| "pm"
|
| "pm"
|
||||||
| "serviceNotes"
|
| "serviceNotes"
|
||||||
| "extraServices"
|
| "extraServices"
|
||||||
| "flagColor";
|
| "flagColor"
|
||||||
|
| "severity";
|
||||||
|
|
||||||
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
|
||||||
export interface WorkOrderBoardRowApi {
|
export interface WorkOrderBoardRowApi {
|
||||||
|
|
@ -144,6 +147,8 @@ export interface WorkOrderBoardRowApi {
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
/** Board flag color (#RRGGBB) or null when cleared. */
|
/** Board flag color (#RRGGBB) or null when cleared. */
|
||||||
flagColor?: string | null;
|
flagColor?: string | null;
|
||||||
|
/** Severity 1–5 for Emergency/Reactive. */
|
||||||
|
severity?: number | null;
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
scheduledDate?: string | null;
|
scheduledDate?: string | null;
|
||||||
targetWeek?: string | null;
|
targetWeek?: string | null;
|
||||||
|
|
|
||||||
75
src/domain/work-orders/types/work-order-severity.ts
Normal file
75
src/domain/work-orders/types/work-order-severity.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
export type WorkOrderSeverity = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
export const WORK_ORDER_SEVERITIES: readonly WorkOrderSeverity[] = [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
/** Types that require an APM severity transcription. */
|
||||||
|
export const SEVERITY_TYPES = ["Emergency", "Reactive"] as const;
|
||||||
|
|
||||||
|
export type SeverityWoType = (typeof SEVERITY_TYPES)[number];
|
||||||
|
|
||||||
|
export type SeverityToken = "critical" | "warning" | "safe";
|
||||||
|
|
||||||
|
export const SEVERITY_LEVELS: Record<
|
||||||
|
WorkOrderSeverity,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
deadline: string;
|
||||||
|
afterHours: string;
|
||||||
|
token: SeverityToken;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
1: {
|
||||||
|
label: "SEV 1",
|
||||||
|
description: "Business Critical Function Down",
|
||||||
|
deadline: "Accept: 15 min · Respond: 2 hrs",
|
||||||
|
afterHours: "Yes",
|
||||||
|
token: "critical",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
label: "SEV 2",
|
||||||
|
description: "Business Critical Function Impaired",
|
||||||
|
deadline: "Accept: 30 min · Respond: 4 hrs",
|
||||||
|
afterHours: "Yes",
|
||||||
|
token: "critical",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
label: "SEV 3",
|
||||||
|
description: "Group Productivity Impaired",
|
||||||
|
deadline: "Accept: 2 hrs · Respond: 8 hrs (next day if after hours)",
|
||||||
|
afterHours: "Approval required",
|
||||||
|
token: "warning",
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
label: "SEV 4",
|
||||||
|
description: "Individual Productivity Impaired",
|
||||||
|
deadline: "Accept: 12 hrs · Respond: 24 hrs (next day)",
|
||||||
|
afterHours: "No",
|
||||||
|
token: "safe",
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
label: "SEV 5",
|
||||||
|
description: "Productivity not immediately affected",
|
||||||
|
deadline: "Accept: 12 hrs · Respond: 72 hrs",
|
||||||
|
afterHours: "No",
|
||||||
|
token: "safe",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEVERITY_TOKEN_COLOR: Record<SeverityToken, string> = {
|
||||||
|
critical: "var(--sla-critical, #DC2626)",
|
||||||
|
warning: "var(--sla-warning, #D97706)",
|
||||||
|
safe: "var(--sla-safe, #16A34A)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function typeNeedsSeverity(type: string): boolean {
|
||||||
|
return (SEVERITY_TYPES as readonly string[]).includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkOrderSeverity(value: unknown): WorkOrderSeverity | null {
|
||||||
|
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||||
|
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||||
|
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
|
||||||
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
site: string;
|
site: string;
|
||||||
type: WOType | "";
|
type: WOType | "";
|
||||||
|
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
|
||||||
|
severity: WorkOrderSeverity | null;
|
||||||
dispatcherId: string;
|
dispatcherId: string;
|
||||||
dispatcherName: string;
|
dispatcherName: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
|
|
@ -55,6 +58,7 @@ export interface WorkOrderTableDraft {
|
||||||
site: string;
|
site: string;
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
type: WOType | "";
|
type: WOType | "";
|
||||||
|
severity: WorkOrderSeverity | null;
|
||||||
dispatcherId: string;
|
dispatcherId: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
schedNotes: string;
|
schedNotes: string;
|
||||||
|
|
@ -82,6 +86,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
||||||
site: "",
|
site: "",
|
||||||
woNumber: "",
|
woNumber: "",
|
||||||
type: "",
|
type: "",
|
||||||
|
severity: null,
|
||||||
dispatcherId: "",
|
dispatcherId: "",
|
||||||
scheduledOn: "",
|
scheduledOn: "",
|
||||||
schedNotes: "",
|
schedNotes: "",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { typeNeedsSeverity, type WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
|
||||||
|
export type { WorkOrderSeverity } from "./work-order-severity";
|
||||||
|
|
||||||
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
|
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
|
||||||
|
|
||||||
export type WOType = (typeof WO_TYPES)[number];
|
export type WOType = (typeof WO_TYPES)[number];
|
||||||
|
|
@ -24,6 +28,8 @@ export interface WorkOrderWizardDraft {
|
||||||
siteCode: string;
|
siteCode: string;
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
type: WOType | "";
|
type: WOType | "";
|
||||||
|
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
|
||||||
|
severity: WorkOrderSeverity | null;
|
||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
assigneeName: string;
|
assigneeName: string;
|
||||||
scheduledOn: string;
|
scheduledOn: string;
|
||||||
|
|
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
siteCode: "",
|
siteCode: "",
|
||||||
woNumber: "",
|
woNumber: "",
|
||||||
type: "",
|
type: "",
|
||||||
|
severity: null,
|
||||||
assigneeId: "",
|
assigneeId: "",
|
||||||
assigneeName: "",
|
assigneeName: "",
|
||||||
scheduledOn: "",
|
scheduledOn: "",
|
||||||
|
|
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
schedMode: undefined,
|
schedMode: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** WO number is optional; when provided must be digits only, 1–20 characters. */
|
/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */
|
||||||
export function isWizardWoNumberValid(woNumber: string): boolean {
|
export function isWizardWoNumberValid(woNumber: string): boolean {
|
||||||
const trimmed = woNumber.trim();
|
const trimmed = woNumber.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return /^\d{1,20}$/.test(trimmed);
|
return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type is optional at creation; empty draft type defaults to PM. */
|
||||||
|
export function effectiveWizardType(type: WOType | ""): WOType {
|
||||||
|
return type || "PM";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardNeedsSeverity(draft: WorkOrderWizardDraft): boolean {
|
||||||
|
return typeNeedsSeverity(draft.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
|
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
|
||||||
|
|
@ -159,11 +175,22 @@ export const TIME_SLOTS: string[] = (() => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
||||||
return Boolean(draft.type) && isWizardWoNumberValid(draft.woNumber);
|
if (!isWizardWoNumberValid(draft.woNumber)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (wizardNeedsSeverity(draft) && draft.severity == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
|
||||||
|
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
|
||||||
|
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
||||||
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
|
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
|
||||||
|
|
@ -171,7 +198,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
|
||||||
return canContinueWizardStep1(draft);
|
return canContinueWizardStep1(draft);
|
||||||
}
|
}
|
||||||
if (step === 2) {
|
if (step === 2) {
|
||||||
return Boolean(draft.locationId && draft.siteCode);
|
return canContinueWizardStep2(draft);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useController } from "react-hook-form";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const rosterGet = vi.fn();
|
const rosterGet = vi.fn();
|
||||||
const saveMutate = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
|
||||||
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
|
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
|
||||||
|
|
@ -29,10 +28,7 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () =>
|
||||||
const actual = await vi.importActual<
|
const actual = await vi.importActual<
|
||||||
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
|
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
|
||||||
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
|
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
|
||||||
return {
|
return { ...actual, useSaveVendorCompanyRoster: () => ({ mutate: vi.fn(), isPending: false }) };
|
||||||
...actual,
|
|
||||||
useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
|
||||||
|
|
@ -105,7 +101,6 @@ function deferRoster(): {
|
||||||
describe("useVendorRosterForm load-error handling", () => {
|
describe("useVendorRosterForm load-error handling", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rosterGet.mockReset();
|
rosterGet.mockReset();
|
||||||
saveMutate.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
|
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
|
||||||
|
|
@ -201,44 +196,6 @@ describe("useVendorRosterForm load-error handling", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useVendorRosterForm prototype defaults", () => {
|
|
||||||
it("does not persist the prototype's untouched optional technician card", () => {
|
|
||||||
const { result } = renderHook(
|
|
||||||
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
|
|
||||||
{ wrapper: makeWrapper(createClient()) },
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.submit({
|
|
||||||
name: "Independent Vendor LLC",
|
|
||||||
companyPhone: "(314) 555-0199",
|
|
||||||
email: "",
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
zip: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
technicians: [
|
|
||||||
{
|
|
||||||
contactName: "",
|
|
||||||
phone: "",
|
|
||||||
email: "",
|
|
||||||
preferredContact: "Phone",
|
|
||||||
tradeSpecialties: "",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(saveMutate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }),
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useVendorRosterForm stale-selection handling", () => {
|
describe("useVendorRosterForm stale-selection handling", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rosterGet.mockReset();
|
rosterGet.mockReset();
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VendorDetailDrawer selected-technician display", () => {
|
describe("VendorDetailDrawer preferred-contact display", () => {
|
||||||
it("renders no preference label when preferredContact is absent", () => {
|
it("renders no preference label when preferredContact is absent", () => {
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
rosterWith([
|
rosterWith([
|
||||||
{ id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
{ id: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -62,15 +62,15 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
||||||
withAuth: false,
|
withAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
expect(screen.getByText("Adam")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the retired preferred-contact value out of the detail view", () => {
|
it("renders the preference label when an explicit preferredContact is present", () => {
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
rosterWith([
|
rosterWith([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 7,
|
||||||
contactName: "Adam",
|
contactName: "Adam",
|
||||||
phone: "314-555-0198",
|
phone: "314-555-0198",
|
||||||
preferredContact: "Email",
|
preferredContact: "Email",
|
||||||
|
|
@ -85,52 +85,6 @@ describe("VendorDetailDrawer selected-technician display", () => {
|
||||||
withAuth: false,
|
withAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
|
expect(screen.getByText("Prefers Email")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the clicked row when the roster has no matching stable id", () => {
|
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
|
||||||
rosterWith([
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
contactName: "Adam Whyte",
|
|
||||||
phone: "999-999-9999",
|
|
||||||
isActive: true,
|
|
||||||
totalJobs: 99,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
|
||||||
route: "/vendors",
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("0")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("999-999-9999")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose a status control for a different technician", () => {
|
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
|
||||||
rosterWith([
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
contactName: "Different Technician",
|
|
||||||
phone: "314-555-0101",
|
|
||||||
email: "",
|
|
||||||
tradeSpecialties: "Plumbing",
|
|
||||||
isActive: true,
|
|
||||||
totalJobs: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<VendorDetailDrawer open={true} vendor={vendor} startInEdit={true} onClose={vi.fn()} />,
|
|
||||||
{ route: "/vendors", withAuth: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("switch", { name: "Active status" })).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { screen, within } 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 { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import VendorsListPage from "@/app/(protected)/vendors/index";
|
import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||||
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
|
import type { VendorListItem } from "@/domain/vendors/types/vendor";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
const useVendorsList = vi.fn();
|
const useVendorsList = vi.fn();
|
||||||
const useVendorCompanyRoster = vi.fn();
|
const useVendorCompanyRoster = vi.fn();
|
||||||
const useVendorDeactivationImpact = vi.fn();
|
const useVendorDeactivationImpact = vi.fn();
|
||||||
const mutate = vi.fn();
|
const mutate = vi.fn();
|
||||||
|
const navigate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("react-router", async () => {
|
||||||
|
const actual = await vi.importActual("react-router");
|
||||||
|
return { ...actual, useNavigate: () => navigate };
|
||||||
|
});
|
||||||
|
|
||||||
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),
|
||||||
}));
|
}));
|
||||||
|
|
@ -54,32 +61,6 @@ const inactiveVendor = {
|
||||||
isActive: false,
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeRoster: VendorCompanyRoster = {
|
|
||||||
companyId: "co-1",
|
|
||||||
rowVersion: "rv-1",
|
|
||||||
name: "Gateway Plumbing",
|
|
||||||
companyPhone: "(314) 555-0100",
|
|
||||||
email: "dispatch@gatewayplumbing.com",
|
|
||||||
address: "",
|
|
||||||
city: "St. Louis",
|
|
||||||
state: "MO",
|
|
||||||
zip: "",
|
|
||||||
googleMapsUrl: "",
|
|
||||||
notes: "",
|
|
||||||
technicians: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
contactName: "Adam Whyte",
|
|
||||||
phone: "(314) 555-0198",
|
|
||||||
email: "dispatch@gatewayplumbing.com",
|
|
||||||
preferredContact: "Email",
|
|
||||||
tradeSpecialties: "Backflow Preventers",
|
|
||||||
isActive: true,
|
|
||||||
totalJobs: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function result(items: VendorListItem[], totalCount: number) {
|
function result(items: VendorListItem[], totalCount: number) {
|
||||||
return {
|
return {
|
||||||
data: { items, totalCount, page: 1, pageSize: 12 },
|
data: { items, totalCount, page: 1, pageSize: 12 },
|
||||||
|
|
@ -90,13 +71,7 @@ function result(items: VendorListItem[], totalCount: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDefaults() {
|
function setupDefaults() {
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null });
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
useVendorDeactivationImpact.mockReturnValue({
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -105,7 +80,7 @@ function setupDefaults() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VendorsListPage", () => {
|
describe("VendorsListPage", () => {
|
||||||
it("renders prototype-aligned status and two row actions in both tabs", async () => {
|
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => {
|
||||||
setupDefaults();
|
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);
|
||||||
|
|
@ -116,20 +91,11 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Vendors / Technicians" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Deactivate vendor" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("cell", { name: "Active" })).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "View vendor Gateway Plumbing" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
|
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
|
||||||
|
|
||||||
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
|
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -148,18 +114,39 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
|
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
useVendorCompanyRoster.mockReturnValue({
|
||||||
data: activeRoster,
|
data: {
|
||||||
|
companyId: "co-1",
|
||||||
|
rowVersion: "rv-1",
|
||||||
|
name: "Gateway Plumbing",
|
||||||
|
companyPhone: "(314) 555-0198",
|
||||||
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
address: "",
|
||||||
|
city: "St. Louis",
|
||||||
|
state: "MO",
|
||||||
|
zip: "",
|
||||||
|
googleMapsUrl: "",
|
||||||
|
notes: "",
|
||||||
|
technicians: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contactName: "Adam Whyte",
|
||||||
|
phone: "(314) 555-0198",
|
||||||
|
email: "dispatch@gatewayplumbing.com",
|
||||||
|
preferredContact: "Email",
|
||||||
|
tradeSpecialties: "Backflow Preventers",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isError: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
});
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], 1);
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
|
@ -174,18 +161,11 @@ describe("VendorsListPage", () => {
|
||||||
row.focus();
|
row.focus();
|
||||||
await userEvent.keyboard("{Enter}");
|
await userEvent.keyboard("{Enter}");
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks deactivation when the preflight reports open work orders", async () => {
|
it("blocks deactivation when the preflight reports open work orders", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
|
||||||
data: activeRoster,
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
useVendorDeactivationImpact.mockReturnValue({
|
useVendorDeactivationImpact.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
vendorId: 1,
|
vendorId: 1,
|
||||||
|
|
@ -209,8 +189,7 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" }));
|
||||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
screen.getByText(/cannot be deactivated because it still has open work orders/),
|
||||||
|
|
@ -220,51 +199,9 @@ describe("VendorsListPage", () => {
|
||||||
expect(mutate).not.toHaveBeenCalled();
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves inline edits when deactivation is cancelled", async () => {
|
it("navigates to the vendor edit route when the table Edit control is clicked", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
navigate.mockClear();
|
||||||
data: activeRoster,
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
useVendorDeactivationImpact.mockReturnValue({
|
|
||||||
data: { vendorId: 1, canDeactivate: true, openWorkOrders: [] },
|
|
||||||
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: "Edit vendor Gateway Plumbing" }));
|
|
||||||
const company = screen.getByRole("textbox", { name: "Company" });
|
|
||||||
await userEvent.clear(company);
|
|
||||||
await userEvent.type(company, "Draft Company Name");
|
|
||||||
await userEvent.click(screen.getByRole("switch", { name: "Active status" }));
|
|
||||||
await userEvent.click(
|
|
||||||
within(screen.getByRole("dialog", { name: "Deactivate Vendor" })).getByRole("button", {
|
|
||||||
name: "Cancel",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(company).toBeInTheDocument();
|
|
||||||
expect(company).toHaveValue("Draft Company Name");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens inline edit when the table Edit control is clicked", async () => {
|
|
||||||
setupDefaults();
|
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
|
||||||
data: activeRoster,
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], 1);
|
if (params.pageSize === 1) return result([], 1);
|
||||||
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
return params.isActive ? result([activeVendor], 1) : result([], 0);
|
||||||
|
|
@ -274,20 +211,16 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
|
||||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
expect(
|
||||||
|
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens inline company edit when the row has no vendor id", async () => {
|
it("navigates to the company route when editing a vendor without a vendor id", async () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
|
navigate.mockClear();
|
||||||
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
|
const companyOnlyVendor = { ...activeVendor, id: null, companyId: "co-9" };
|
||||||
useVendorCompanyRoster.mockReturnValue({
|
|
||||||
data: { ...activeRoster, companyId: "co-9", technicians: [] },
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
if (params.pageSize === 1) return result([], 1);
|
if (params.pageSize === 1) return result([], 1);
|
||||||
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
|
return params.isActive ? result([companyOnlyVendor], 1) : result([], 0);
|
||||||
|
|
@ -297,7 +230,6 @@ describe("VendorsListPage", () => {
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
|
expect(navigate).toHaveBeenCalledWith("/vendors/company/co-9");
|
||||||
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const canceledRow: WorkOrderTableRow = {
|
||||||
woNumber: "WO-77",
|
woNumber: "WO-77",
|
||||||
site: "CHI-1042",
|
site: "CHI-1042",
|
||||||
type: "PM",
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Test Dispatcher",
|
dispatcherName: "Test Dispatcher",
|
||||||
scheduledOn: "2026-07-20",
|
scheduledOn: "2026-07-20",
|
||||||
|
|
@ -208,9 +209,15 @@ describe("WorkOrdersListPage board-core public interface", () => {
|
||||||
|
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
screen.getByPlaceholderText(/leave blank for sh provisional number/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
|
||||||
|
expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-
|
||||||
import {
|
import {
|
||||||
EMPTY_WIZARD_DRAFT,
|
EMPTY_WIZARD_DRAFT,
|
||||||
canContinueWizardStep1,
|
canContinueWizardStep1,
|
||||||
|
canContinueWizardStep2,
|
||||||
canCreateWorkOrderFromWizard,
|
canCreateWorkOrderFromWizard,
|
||||||
isWizardStepValid,
|
isWizardStepValid,
|
||||||
|
isWizardWoNumberValid,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
describe("mapWizardDraftToFormValues", () => {
|
describe("mapWizardDraftToFormValues", () => {
|
||||||
|
|
@ -63,12 +65,18 @@ describe("mapWizardDraftToFormValues", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("wizard step validation", () => {
|
describe("wizard step validation", () => {
|
||||||
it("blocks step 1 continue without type", () => {
|
it("allows step 1 continue without a woNumber for backend SH generation", () => {
|
||||||
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
|
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
|
||||||
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
|
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows step 1 when type is set without woNumber", () => {
|
it("allows step 1 without type when woNumber is set (type defaults to PM)", () => {
|
||||||
|
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
|
||||||
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
|
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows step 1 when woNumber is missing even with type", () => {
|
||||||
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
|
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
|
||||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||||
|
|
@ -84,6 +92,51 @@ describe("wizard step validation", () => {
|
||||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks step 1 for Emergency without severity", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "Emergency" as const,
|
||||||
|
woNumber: "12345",
|
||||||
|
};
|
||||||
|
expect(canContinueWizardStep1(draft)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows step 1 for Emergency with severity", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "Emergency" as const,
|
||||||
|
woNumber: "12345",
|
||||||
|
severity: 2 as const,
|
||||||
|
};
|
||||||
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks step 2 without POC name/phone", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM" as const,
|
||||||
|
woNumber: "12345",
|
||||||
|
locationId: "99",
|
||||||
|
siteCode: "BK5",
|
||||||
|
};
|
||||||
|
expect(canContinueWizardStep2(draft)).toBe(false);
|
||||||
|
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows step 2 when site and POC are set", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM" as const,
|
||||||
|
woNumber: "12345",
|
||||||
|
locationId: "99",
|
||||||
|
siteCode: "BK5",
|
||||||
|
pocName: "Jordan",
|
||||||
|
pocPhone: "555-0100",
|
||||||
|
};
|
||||||
|
expect(canContinueWizardStep2(draft)).toBe(true);
|
||||||
|
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks create without siteCode", () => {
|
it("blocks create without siteCode", () => {
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
@ -95,44 +148,54 @@ describe("wizard step validation", () => {
|
||||||
expect(isWizardStepValid(2, draft)).toBe(false);
|
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows create when type and siteCode are set (woNumber optional)", () => {
|
it("allows create when type, woNumber, site, and POC are set", () => {
|
||||||
const draft = {
|
|
||||||
...EMPTY_WIZARD_DRAFT,
|
|
||||||
type: "PM" as const,
|
|
||||||
locationId: "99",
|
|
||||||
siteCode: "BK5",
|
|
||||||
};
|
|
||||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
|
||||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows create when type, woNumber, and siteCode are set", () => {
|
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
type: "PM" as const,
|
type: "PM" as const,
|
||||||
woNumber: "12345678901234567890",
|
woNumber: "12345678901234567890",
|
||||||
locationId: "99",
|
locationId: "99",
|
||||||
siteCode: "BK5",
|
siteCode: "BK5",
|
||||||
|
pocName: "Jordan",
|
||||||
|
pocPhone: "555-0100",
|
||||||
};
|
};
|
||||||
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
|
||||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows step 1 when woNumber is empty", () => {
|
it("allows provisional SH-prefixed woNumber", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
woNumber: "SH12345",
|
||||||
|
};
|
||||||
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts blank woNumber for backend SH generation", () => {
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
type: "PM" as const,
|
type: "PM" as const,
|
||||||
woNumber: "",
|
woNumber: "",
|
||||||
};
|
};
|
||||||
|
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
|
||||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks step 1 when woNumber is non-numeric", () => {
|
it("accepts whitespace-only woNumber for backend SH generation", () => {
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
type: "PM" as const,
|
type: "PM" as const,
|
||||||
woNumber: "abc",
|
woNumber: " ",
|
||||||
};
|
};
|
||||||
expect(canContinueWizardStep1(draft)).toBe(false);
|
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
|
||||||
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-alphanumeric woNumber", () => {
|
||||||
|
expect(isWizardWoNumberValid("!!!")).toBe(false);
|
||||||
|
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts alphanumeric woNumber", () => {
|
||||||
|
expect(isWizardWoNumberValid("ABC123")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -234,14 +234,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.workOrderType).toBe(3);
|
expect(payload.workOrderType).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when type is missing", () => {
|
it("defaults empty type to PM", () => {
|
||||||
expect(() =>
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
mapWizardDraftToBoardCreatePayload({
|
...EMPTY_WIZARD_DRAFT,
|
||||||
...EMPTY_WIZARD_DRAFT,
|
siteCode: "BK5",
|
||||||
|
});
|
||||||
siteCode: "BK5",
|
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.PM);
|
||||||
}),
|
|
||||||
).toThrow("Work order type is required");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when siteCode cannot be resolved", () => {
|
it("throws when siteCode cannot be resolved", () => {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,12 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
|
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
|
||||||
{ field: "flagColor", value: "" },
|
{ field: "flagColor", value: "" },
|
||||||
]);
|
]);
|
||||||
|
expect(expandTablePatchToBoardFieldOps({ severity: 3 })).toEqual([
|
||||||
|
{ field: "severity", value: "3" },
|
||||||
|
]);
|
||||||
|
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
|
||||||
|
{ field: "severity", value: "" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
|
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
|
||||||
|
|
|
||||||
|
|
@ -94,11 +94,13 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
|
||||||
docStatus: 1,
|
docStatus: 1,
|
||||||
dayGroup: "wednesday",
|
dayGroup: "wednesday",
|
||||||
flagColor: "#EF4444",
|
flagColor: "#EF4444",
|
||||||
|
severity: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(row.woNumber).toBe("10000001235");
|
expect(row.woNumber).toBe("10000001235");
|
||||||
expect(row.site).toBe("BK5");
|
expect(row.site).toBe("BK5");
|
||||||
expect(row.type).toBe("Reactive");
|
expect(row.type).toBe("Reactive");
|
||||||
|
expect(row.severity).toBe(2);
|
||||||
expect(row.status).toBe("Scheduled");
|
expect(row.status).toBe("Scheduled");
|
||||||
expect(row.scheduledOn).toBe("2026-07-15");
|
expect(row.scheduledOn).toBe("2026-07-15");
|
||||||
expect(row.dueDate).toBe("2026-07-18");
|
expect(row.dueDate).toBe("2026-07-18");
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
woNumber: "1001",
|
woNumber: "1001",
|
||||||
site: "SITE-A",
|
site: "SITE-A",
|
||||||
type: "PM",
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
dispatcherId: "10",
|
dispatcherId: "10",
|
||||||
dispatcherName: "Alex",
|
dispatcherName: "Alex",
|
||||||
scheduledOn: "2026-07-16",
|
scheduledOn: "2026-07-16",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
woNumber: "12345678901",
|
woNumber: "12345678901",
|
||||||
site: "SITE01",
|
site: "SITE01",
|
||||||
type: "PM",
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Alice",
|
dispatcherName: "Alice",
|
||||||
scheduledOn: "2026-06-23",
|
scheduledOn: "2026-06-23",
|
||||||
|
|
|
||||||
43
tmp/pr-descriptions/pr2-severity-wizard.md
Normal file
43
tmp/pr-descriptions/pr2-severity-wizard.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
## Jira
|
||||||
|
|
||||||
|
- [SH-192](https://luby-us.atlassian.net/browse/SH-192) — severity on Emergency/Reactive
|
||||||
|
- Related: [SH-107](https://luby-us.atlassian.net/browse/SH-107) (WO# provisional SH…), [SH-108](https://luby-us.atlassian.net/browse/SH-108) (POC)
|
||||||
|
|
||||||
|
## QA residual
|
||||||
|
|
||||||
|
- P0 wizard fixed: Type optional (default PM); WO# alphanumeric / SH provisional (not digits-only).
|
||||||
|
- P1: COL_HELP tooltips, Scheduled-requires-date — follow-up.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Add SEV 1–5 domain field on board row/create/patch and Type cell modal for Emergency/Reactive.
|
||||||
|
- Wizard gates: required WO# (alphanumeric/SH ok), Type optional (defaults PM), severity when E/R, POC name/phone on step 2 with error UX.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In
|
||||||
|
|
||||||
|
- `severity` on WorkOrderBoardRowApi / create payload / editable field / table row / wizard draft
|
||||||
|
- Type cell popover + TypeSeverityModal + SeverityBadge
|
||||||
|
- Wizard step1/step2 validation + footer attempt-continue errors
|
||||||
|
- Unit tests for canContinue* and severity patch mapping
|
||||||
|
|
||||||
|
### Out (follow-up PRs)
|
||||||
|
|
||||||
|
- Advanced filter severity facet (PR5)
|
||||||
|
- Uplift UI, Completed Date column, multi-POC, SH auto-numbering beyond wizard gate
|
||||||
|
|
||||||
|
## API / BE
|
||||||
|
|
||||||
|
- Contract assumed: `severity` on GET board/search row, POST create, and PATCH field `severity`
|
||||||
|
- Blocked on BE: none for FE wiring; 422 if severity missing for E/R should be handled by BE
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Board: pick Emergency/Reactive → severity modal required; badge shows SEV
|
||||||
|
- [ ] Wizard: cannot continue step1 without WO#; E/R without severity; step2 without POC
|
||||||
|
- [ ] `npm run verify` green locally
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
- Base branch: `dev`
|
||||||
Loading…
Add table
Reference in a new issue