fix(work-orders): allow blank WO number in wizard create path

[recover] remove malicious eslint payload (was d0d08508)
This commit is contained in:
Arthur Bassi 2026-08-10 17:12:11 -03:00
parent 78ed8571a0
commit 48702c3fc4
46 changed files with 1758 additions and 1575 deletions

View file

@ -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();

View file

@ -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"),
}); });
}; };

View file

@ -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);

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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} />
</>
);
}

View file

@ -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>

View file

@ -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>
); );
} }

View file

@ -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>
); );
} }

View file

@ -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}
/> />
)) ))
)} )}

View file

@ -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

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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&apos;s ticket — this is not a
judgment call.
</Text>
<SeverityOptionsList value={value} onSelect={onSelect} />
</div>
);
}

View file

@ -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}
/>
</> </>
); );
} }

View file

@ -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 &amp; 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>
);
}

View file

@ -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" }}>

View file

@ -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"

View file

@ -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>
); );

View file

@ -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,
};
}

View file

@ -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

View file

@ -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>
</>
);
}

View file

@ -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>

View file

@ -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&apos;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>

View file

@ -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>

View file

@ -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;
}
} }

View file

@ -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[] {

View file

@ -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),
}; };
} }

View file

@ -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 });

View file

@ -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,

View file

@ -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;

View 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;
}

View file

@ -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: "",

View file

@ -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;
} }

View file

@ -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();

View file

@ -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();
}); });
}); });

View file

@ -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");
}); });
}); });

View file

@ -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"));

View file

@ -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);
}); });
}); });

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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");

View file

@ -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",

View file

@ -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",

View 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`