chore(work-orders): merge origin/dev and keep SH-114 closability gate

This commit is contained in:
arthur.bassi 2026-08-17 09:09:43 -03:00
commit bc97f517f9
101 changed files with 4504 additions and 1510 deletions

View file

@ -306,11 +306,18 @@ 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");
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); await expect(heading).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",
@ -323,52 +330,58 @@ 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 vendor entries · 2 active · 1 inactive")).toBeVisible(); await expect(page.getByText("2 active technicians")).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 vendor entry · filtered/)).toBeVisible(); await expect(page.getByText(/1 active technician · 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" }).click(); await page.getByRole("button", { name: "Apply filters" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); await expect(page.getByText("1 technician · filtered by")).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 vendors match the current filters.")).toBeVisible(); await expect(page.getByText("No inactive technicians found")).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);
}); });
@ -378,33 +391,35 @@ 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" }).click(); await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect( await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
page.getByText("Register a company and add zero, one, or multiple technicians."),
).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test", "dispatch@gateway.test",
); );
await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"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").last().fill("New Technician"); await page.getByLabel("Technician name (optional)").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").fill("Created in browser E2E"); await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
expect(state.updatedBody).toMatchObject({ expect(state.updatedBody).toMatchObject({
name: "Gateway Plumbing", name: "Gateway Plumbing",
companyPhone: "(314) 555-0100", companyPhone: "(314) 555-0100",
@ -429,7 +444,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: "Gateway Plumbing" })).toBeVisible(); await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).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(
@ -438,9 +453,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\/edit\/1$/); await expect(page).toHaveURL(/\/vendors$/);
await page.getByLabel("Notes").fill("Updated in browser E2E"); await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
await page.getByRole("button", { name: "Save" }).click(); await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
}); });
@ -448,18 +463,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: "Deactivate vendor" }).first().click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).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: "View vendor Gateway Plumbing" }).click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last(); const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("button", { name: "Edit" }).click(); await detailDrawer.getByRole("switch", { name: "Active status" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/); await detailDrawer.getByRole("button", { name: "Save changes" }).click();
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();
}); });
@ -468,7 +483,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" }); const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
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();
@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => {
await expect(addButton).toBeFocused(); await expect(addButton).toBeFocused();
await addButton.click(); await addButton.click();
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); await expect(
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").fill("3145550199"); await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await page.getByRole("button", { name: "Add Vendor", exact: true }).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([]);
@ -506,7 +523,8 @@ 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: "Deactivate vendor" }).first().click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).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();
@ -517,7 +535,8 @@ 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: "Deactivate vendor" }).first().click(); await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).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", {
@ -555,6 +574,7 @@ 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

@ -2,11 +2,5 @@
"version": 1, "version": 1,
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.", "purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
"maxFileLines": 500, "maxFileLines": 500,
"godfileDebt": [ "godfileDebt": []
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
}
]
} }

View file

@ -15,9 +15,7 @@ export interface VendorDeactivationState {
confirm: () => void; confirm: () => void;
} }
export function useVendorDeactivation(options?: { export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
onSuccess?: () => void;
}): 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();
@ -43,7 +41,7 @@ export function useVendorDeactivation(options?: {
deleteVendor.mutate(target.id, { deleteVendor.mutate(target.id, {
onSuccess: () => { onSuccess: () => {
setTarget(null); setTarget(null);
options?.onSuccess?.(); 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(12); const [pageSize, setPageSize] = useState(48);
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,6 +2,7 @@ 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";
@ -43,6 +44,7 @@ export interface VendorRosterFormProps {
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 {
@ -73,6 +75,7 @@ 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,
@ -87,9 +90,16 @@ 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: emptyVendorCompanyRosterForm, defaultValues: createDefaults,
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
mode: "onChange", mode: "onChange",
}); });
@ -114,10 +124,10 @@ export function useVendorRosterForm({
}, [routeRoster, watched]); }, [routeRoster, watched]);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
reset(emptyVendorCompanyRosterForm); reset(createDefaults);
resetSelection(); resetSelection();
clearConflict(); clearConflict();
}, [clearConflict, reset, resetSelection]); }, [clearConflict, createDefaults, 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;

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,7 +21,11 @@ interface VendorCreateModalProps {
} }
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); const form = useVendorRosterForm({
mode: "create",
onSuccess: onClose,
startWithTechnician: true,
});
const { resetForm } = form; const { resetForm } = form;
useEffect(() => { useEffect(() => {
@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
open={open} open={open}
onClose={form.isSaving ? undefined : onClose} onClose={form.isSaving ? undefined : onClose}
fullWidth fullWidth
maxWidth="md" maxWidth="sm"
aria-labelledby="vendor-create-dialog-title" aria-labelledby="vendor-create-dialog-title"
slotProps={{ paper: { role: "dialog" } }} slotProps={{
paper: {
role: "dialog",
sx: { maxHeight: "88vh", borderRadius: 2 },
},
}}
> >
<DialogTitle id="vendor-create-dialog-title"> <DialogTitle id="vendor-create-dialog-title">
Add Vendor Company <Text variant="title" as="span">
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}> Add Vendor
Register a company and add zero, one, or multiple technicians. </Text>
</Typography> <Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
Register a vendor company and its technicians.
</Text>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent sx={{ maxHeight: "55vh" }}>
<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} />
@ -73,9 +84,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 || !form.isValid} disabled={form.isSaving}
> >
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"} {form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View file

@ -1,5 +1,5 @@
import { useEffect, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { useNavigate } from "react-router"; import { Controller, useWatch } from "react-hook-form";
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,15 +13,22 @@ import {
Drawer, Drawer,
IconButton, IconButton,
Link, Link,
List,
ListItem,
Stack, Stack,
Typography, Switch,
} 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 = 440; const DRAWER_WIDTH = 420;
function splitTrades(value: string | undefined): string[] { function splitTrades(value: string | undefined): string[] {
return (value ?? "") return (value ?? "")
@ -33,106 +40,45 @@ 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 (
<Typography <Text
variant="subtitle2" variant="label"
className="font-semibold uppercase" tone="muted"
sx={{ color: "text.secondary", mb: 1 }} className="font-semibold tracking-[0.06em] uppercase"
sx={{ mb: 1 }}
> >
{children} {children}
</Typography> </Text>
); );
} }
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>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}> <Text variant="caption" tone="muted" sx={{ display: "block" }}>
{label} {label}
</Typography> </Text>
<Typography variant="body2">{display}</Typography> <Text variant="description">{display}</Text>
</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 (
@ -141,23 +87,18 @@ function DrawerHeader({
sx={{ sx={{
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
px: 3, px: 2.5,
py: 2, py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`, borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}} }}
> >
<Box> <Box>
<Typography <Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
id="vendor-detail-drawer-title" {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
variant="subtitle1" </Text>
component="h2" <Text variant="description" tone="muted">
className="font-semibold" {roster?.name || vendor?.companyName || "Vendor details"}
> </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" />
@ -167,18 +108,19 @@ function DrawerHeader({
} }
function CompanySection({ roster }: { roster: VendorCompanyRoster }) { function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const mapsUrl = roster.googleMapsUrl; const address = [roster.address, roster.city, roster.state, roster.zip]
.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="Email" value={roster.email} /> <Field label="Company Email" value={roster.email} />
<Field <Field label="Address" value={address} />
label="Address" {Boolean(roster.googleMapsUrl) && (
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
/>
{Boolean(mapsUrl) && (
<Link <Link
href={mapsUrl} href={roster.googleMapsUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1" className="inline-flex items-center gap-1"
@ -191,49 +133,79 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
); );
} }
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) { function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
const trades = splitTrades(technician.tradeSpecialties);
return ( return (
<Box> <Box>
<SectionTitle>Technicians</SectionTitle> <Stack spacing={1.5}>
{roster.technicians.length === 0 ? ( <Field label="Phone" value={technician.phone} />
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Box>
No technicians assigned to this company. <Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
</Typography> Trade
) : ( </Text>
<List disablePadding> <Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
{roster.technicians.map((technician) => ( {trades.length > 0 ? (
<TechnicianEntry trades.map((trade, index) => (
key={String(technician.id ?? technician.contactName)} <Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
contactName={technician.contactName} ))
phone={technician.phone} ) : (
email={technician.email} <Text variant="description">—</Text>
preferredContact={technician.preferredContact} )}
tradeSpecialties={technician.tradeSpecialties} </Stack>
isActive={technician.isActive} </Box>
totalJobs={technician.totalJobs} <Stack direction="row" spacing={4} sx={{ alignItems: "flex-end" }}>
<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>
</List> </Stack>
)} </Stack>
</Box> </Box>
); );
} }
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { function DrawerBody({
roster,
technician,
}: {
roster: VendorCompanyRoster;
technician?: VendorRosterTechnician;
}) {
return ( return (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}> <Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
<SectionTitle>Company</SectionTitle> <Box>
<CompanySection roster={roster} /> <SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
</Box>
<Divider /> <Divider />
<TechniciansSection roster={roster} /> {technician ? (
<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>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}> <Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
{roster.notes} {roster.notes}
</Typography> </Text>
</Box> </Box>
</> </>
)} )}
@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
<Stack <Stack
direction="row" direction="row"
sx={{ sx={{
px: 3, px: 2.5,
py: 2, py: 1.5,
borderTop: (theme) => `1px solid ${theme.palette.divider}`, borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
); );
} }
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { function DrawerEditor({
const navigate = useNavigate(); vendor,
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;
@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
!hasVendorId ? (companyId ?? undefined) : undefined, !hasVendorId ? (companyId ?? undefined) : undefined,
); );
const editHref = hasVendorId const selectedTechnician = resolveTechnician(roster, vendor);
? `/vendors/edit/${vendorId}`
: `/vendors/company/${companyId ?? ""}`; useEffect(() => {
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") onClose(); if (event.key === "Escape" && !isEditing) onClose();
}; };
window.addEventListener("keydown", closeOnEscape); window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape);
}, [onClose, open]); }, [isEditing, onClose, open]);
return ( return (
<Drawer <Drawer
open={open} open={open}
onClose={onClose} onClose={(_event, reason) => {
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 }}
@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
}} }}
> >
<Stack className="flex h-full flex-col"> <Stack className="flex h-full flex-col">
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} /> <DrawerHeader
roster={roster}
vendor={vendor}
technician={selectedTechnician}
onClose={onClose}
/>
{isLoading ? ( {isEditing && vendor ? (
<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>
@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
</Alert> </Alert>
) : roster ? ( ) : roster ? (
<> <>
<DrawerBody roster={roster} /> <DrawerBody roster={roster} technician={selectedTechnician} />
<DrawerActions onEdit={() => navigate(editHref)} /> <DrawerActions onEdit={() => setIsEditing(true)} />
</> </>
) : ( ) : (
<Box className="flex flex-1 items-center justify-center"> <Box className="flex flex-1 items-center justify-center">
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Text variant="description" tone="muted">
No vendor selected. No vendor selected.
</Typography> </Text>
</Box> </Box>
)} )}
</Stack> </Stack>

View file

@ -0,0 +1,252 @@
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,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
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 { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import type { VendorFacets } from "@/domain/vendors/types/vendor"; import type { VendorFacets } from "@/domain/vendors/types/vendor";
import { VendorFilterOptions } from "./vendor-filter-options";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
interface VendorFiltersDrawerProps { interface VendorFiltersDrawerProps {
open: boolean; open: boolean;
@ -30,11 +30,6 @@ export function VendorFiltersDrawer({
} }
}, [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;
@ -84,16 +79,7 @@ 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 }}>
<VendorFiltersDrawerFields <VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
draft={draft}
setDraft={setDraft}
options={{
companies: companyOptions,
trades: tradeOptions,
locations: locationOptions,
jobBuckets: jobBucketOptions,
}}
/>
</Box> </Box>
<Stack <Stack
@ -109,7 +95,7 @@ export function VendorFiltersDrawer({
Clear all Clear all
</Button> </Button>
<Button variant="contained" onClick={handleApply} fullWidth> <Button variant="contained" onClick={handleApply} fullWidth>
Apply Apply filters
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>

View file

@ -1,10 +1,4 @@
import { import { Controller, useFieldArray, type Control, type FieldErrors } from "react-hook-form";
Controller,
useFieldArray,
useWatch,
type Control,
type FieldErrors,
} from "react-hook-form";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import { import {
@ -34,7 +28,7 @@ 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;
onRequestDeactivate?: (technician: RosterTechnicianValues) => void; showTechnicianStatus?: boolean;
} }
function CompanyNameField({ function CompanyNameField({
@ -61,6 +55,7 @@ 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}
@ -105,8 +100,9 @@ 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 ?? "Choose an existing company or enter a new one"} helperText={errors.name?.message}
inputRef={field.ref} inputRef={field.ref}
/> />
)} )}
@ -126,10 +122,7 @@ function CompanyFields({
}: VendorRosterFormFieldsProps) { }: VendorRosterFormFieldsProps) {
return ( return (
<Box> <Box>
<Typography variant="subtitle2" className="font-semibold uppercase"> <Stack spacing={2}>
Company
</Typography>
<Stack spacing={2} className="mt-2">
<CompanyNameField <CompanyNameField
control={control} control={control}
errors={errors} errors={errors}
@ -145,13 +138,13 @@ function CompanyFields({
render={({ field }) => ( render={({ field }) => (
<TextField <TextField
{...field} {...field}
label="Company Phone" label="Company Phone (optional)"
placeholder="(XXX) XXX-XXXX" placeholder="(214) 555-0100"
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 ?? "Phone or email required"} helperText={errors.companyPhone?.message}
fullWidth fullWidth
/> />
)} )}
@ -162,7 +155,8 @@ function CompanyFields({
render={({ field }) => ( render={({ field }) => (
<TextField <TextField
{...field} {...field}
label="Email" label="Email (optional)"
placeholder="dispatch@vendor.com"
type="email" type="email"
error={Boolean(errors.email)} error={Boolean(errors.email)}
helperText={errors.email?.message} helperText={errors.email?.message}
@ -174,9 +168,16 @@ function CompanyFields({
<Controller <Controller
control={control} control={control}
name="address" name="address"
render={({ field }) => <TextField {...field} label="Address" fullWidth />} render={({ field }) => (
<TextField
{...field}
label="Address (optional)"
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
fullWidth
/>
)}
/> />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
<Controller <Controller
control={control} control={control}
name="city" name="city"
@ -201,10 +202,10 @@ function CompanyFields({
render={({ field }) => ( render={({ field }) => (
<TextField <TextField
{...field} {...field}
label="Google Maps URL" label="Google Maps URL (optional)"
placeholder="https://maps.google.com/..." placeholder="https://maps.google.com/…"
error={Boolean(errors.googleMapsUrl)} error={Boolean(errors.googleMapsUrl)}
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"} helperText={errors.googleMapsUrl?.message}
fullWidth fullWidth
/> />
)} )}
@ -213,7 +214,14 @@ function CompanyFields({
control={control} control={control}
name="notes" name="notes"
render={({ field }) => ( render={({ field }) => (
<TextField {...field} label="Notes" multiline minRows={2} fullWidth /> <TextField
{...field}
label="Notes (optional)"
placeholder="Internal notes, scheduling preferences, certifications…"
multiline
minRows={2}
fullWidth
/>
)} )}
/> />
</Stack> </Stack>
@ -228,7 +236,7 @@ interface TechnicianRowProps {
onRemove: () => void; onRemove: () => void;
canRemove: boolean; canRemove: boolean;
tradeOptions: string[]; tradeOptions: string[];
onRequestDeactivate?: (technician: RosterTechnicianValues) => void; showStatus: boolean;
} }
function TechnicianRow({ function TechnicianRow({
@ -238,15 +246,13 @@ function TechnicianRow({
onRemove, onRemove,
canRemove, canRemove,
tradeOptions, tradeOptions,
onRequestDeactivate, showStatus,
}: TechnicianRowProps) { }: TechnicianRowProps) {
const technician = useWatch({ control, name: `technicians.${index}` });
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">
Technician {index + 1} {index + 1} - TECHNICIAN
</Typography> </Typography>
<Tooltip title="Remove technician"> <Tooltip title="Remove technician">
<span> <span>
@ -267,7 +273,8 @@ function TechnicianRow({
render={({ field }) => ( render={({ field }) => (
<TextField <TextField
{...field} {...field}
label="Technician Name" label="Technician name (optional)"
placeholder="Technician name"
error={Boolean(error?.contactName)} error={Boolean(error?.contactName)}
helperText={error?.contactName?.message} helperText={error?.contactName?.message}
fullWidth fullWidth
@ -281,8 +288,8 @@ function TechnicianRow({
render={({ field }) => ( render={({ field }) => (
<TextField <TextField
{...field} {...field}
label="Technician Phone" label="Technician phone (optional)"
placeholder="(XXX) XXX-XXXX" placeholder="(000) 000-0000"
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))}
@ -303,32 +310,29 @@ 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} />
<Controller {showStatus && (
control={control} <Controller
name={`technicians.${index}.isActive`} control={control}
render={({ field }) => ( name={`technicians.${index}.isActive`}
<FormControlLabel render={({ field }) => (
control={ <FormControlLabel
<Switch control={
checked={field.value} <Switch
onChange={(_event, checked) => { checked={field.value}
if (!checked && technician?.id != null && onRequestDeactivate != null) { onChange={(_event, checked) => field.onChange(checked)}
onRequestDeactivate(technician); />
return; }
} label={field.value ? "Active" : "Inactive"}
field.onChange(checked); />
}} )}
/> />
} )}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
</Stack> </Stack>
); );
} }
@ -337,12 +341,12 @@ function TechniciansFieldArray({
control, control,
errors, errors,
tradeOptions, tradeOptions,
onRequestDeactivate, showStatus,
}: { }: {
control: Control<VendorCompanyRosterFormValues>; control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>; errors: FieldErrors<VendorCompanyRosterFormValues>;
tradeOptions: string[]; tradeOptions: string[];
onRequestDeactivate?: (technician: RosterTechnicianValues) => void; showStatus: boolean;
}) { }) {
const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
@ -385,7 +389,7 @@ function TechniciansFieldArray({
onRemove={() => remove(index)} onRemove={() => remove(index)}
canRemove canRemove
tradeOptions={tradeOptions} tradeOptions={tradeOptions}
onRequestDeactivate={onRequestDeactivate} showStatus={showStatus}
/> />
)) ))
)} )}
@ -395,7 +399,7 @@ function TechniciansFieldArray({
} }
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
const { control, errors, tradeOptions = [], onRequestDeactivate } = props; const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
return ( return (
<Stack spacing={3}> <Stack spacing={3}>
<CompanyFields {...props} /> <CompanyFields {...props} />
@ -404,7 +408,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
control={control} control={control}
errors={errors} errors={errors}
tradeOptions={tradeOptions} tradeOptions={tradeOptions}
onRequestDeactivate={onRequestDeactivate} showStatus={showTechnicianStatus}
/> />
</Stack> </Stack>
); );

View file

@ -1,44 +1,17 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { VendorDeactivationDialog } from "./vendor-deactivation-dialog";
import { VendorPortalTokenPanel } from "./vendor-portal-token-panel"; import { VendorPortalTokenPanel } from "./vendor-portal-token-panel";
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";
import { useVendorDeactivation } from "./use-vendor-deactivation";
import { useVendorRosterForm } from "./use-vendor-roster-form"; import { useVendorRosterForm } from "./use-vendor-roster-form";
import type { RosterTechnicianValues } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
interface VendorRosterPageProps { interface VendorRosterPageProps {
vendorId?: string; vendorId?: string;
companyId?: string; companyId?: string;
} }
function toDeactivationListItem(
roster: VendorCompanyRoster,
technician: RosterTechnicianValues,
): VendorListItem {
return {
id: technician.id ?? null,
companyId: roster.companyId,
companyName: roster.name,
contactName: technician.contactName,
email: technician.email,
phone: technician.phone,
companyPhone: roster.companyPhone,
googleMapsUrl: roster.googleMapsUrl,
notes: roster.notes,
totalJobs: 0,
city: roster.city,
state: roster.state,
tradeSpecialties: technician.tradeSpecialties,
isActive: technician.isActive,
preferredContact: technician.preferredContact ?? "Phone",
};
}
function PageShell({ function PageShell({
title, title,
subtitle, subtitle,
@ -101,9 +74,6 @@ function submitLabel(isSaving: boolean, isEdit: boolean): string {
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined; const isEdit = vendorId !== undefined || companyId !== undefined;
const deactivation = useVendorDeactivation({
onSuccess: () => navigate("/vendors"),
});
const form = useVendorRosterForm({ const form = useVendorRosterForm({
mode: isEdit ? "update" : "create", mode: isEdit ? "update" : "create",
vendorId, vendorId,
@ -111,11 +81,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSuccess: () => navigate("/vendors"), onSuccess: () => navigate("/vendors"),
}); });
const handleRequestDeactivate = (technician: RosterTechnicianValues) => {
if (!form.roster || technician.id == null) return;
deactivation.open(toDeactivationListItem(form.roster, technician));
};
if (isEdit && form.isLoading) { if (isEdit && form.isLoading) {
return ( return (
<Box className="flex justify-center p-10"> <Box className="flex justify-center p-10">
@ -159,7 +124,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
control={form.control} control={form.control}
errors={form.errors} errors={form.errors}
tradeOptions={form.trades} tradeOptions={form.trades}
onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined}
{...companySelectionProps} {...companySelectionProps}
/> />
@ -181,17 +145,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
</Stack> </Stack>
</Stack> </Stack>
</Box> </Box>
<VendorDeactivationDialog
target={deactivation.target}
isLoading={deactivation.isLoading}
isSaving={deactivation.isSaving}
error={deactivation.error}
impact={deactivation.impact}
impactError={deactivation.impactError}
onClose={deactivation.close}
onConfirm={deactivation.confirm}
/>
</PageShell> </PageShell>
); );
} }

View file

@ -1,13 +1,9 @@
import FilterListIcon from "@mui/icons-material/FilterList"; import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material";
import { import { Plus, SlidersHorizontal } from "lucide-react";
Button, import { PageHeader } from "@/components/ui/page-header";
Chip, import { SearchInput } from "@/components/ui/search-input";
CircularProgress, import { Text } from "@/components/ui/text";
Stack, import { ToolbarButton } from "@/components/ui/toolbar-button";
TablePagination,
TextField,
Typography,
} from "@mui/material";
interface VendorsListToolbarProps { interface VendorsListToolbarProps {
globalCount: number; globalCount: number;
@ -33,55 +29,46 @@ export function VendorsListToolbar({
onAddVendor, onAddVendor,
}: VendorsListToolbarProps) { }: VendorsListToolbarProps) {
return ( return (
<Stack <PageHeader
direction={{ xs: "column", md: "row" }} surface
spacing={2} className="-mx-4 -mt-4"
sx={{ title="Vendors / Technicians"
alignItems: { xs: "stretch", md: "center" }, subtitle={`${globalCount} technicians · ${activeCount} active`}
justifyContent: "space-between", actions={
}} <>
> <SearchInput
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}> value={search}
<Typography variant="h5" component="h1"> onChange={onSearchChange}
Vendors / Technicians placeholder="Search name, company, trade…"
</Typography> aria-label="Search vendors"
<Typography variant="body2" sx={{ color: "text.secondary" }}> fullWidth
{globalCount} vendor entries · {activeCount} active className="w-full md:w-56"
</Typography> inputClassName="h-8 py-0 text-[13px]"
{isFetching && !isLoading && ( />
<CircularProgress size={16} aria-label="Refreshing vendors" role="status" /> <ToolbarButton
)} variant="outline"
</Stack> icon={<SlidersHorizontal size={16} />}
onClick={onOpenFilters}
<Stack aria-label="Open vendor filters"
direction={{ xs: "column", sm: "row" }} badge={appliedFilterCount}
spacing={2} className="min-w-[88.609375px] flex-1 text-[13px] md:w-[88.609375px] md:flex-none"
sx={{ alignItems: { xs: "stretch", sm: "center" } }} >
> Filters
<TextField </ToolbarButton>
size="small" <ToolbarButton
value={search} variant="primary"
onChange={(e) => onSearchChange(e.target.value)} icon={<Plus size={16} />}
placeholder="Search name, company, trade..." onClick={onAddVendor}
className="min-w-[260px]" className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none"
aria-label="Search vendors" >
/> 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>
); );
} }
@ -93,16 +80,21 @@ interface AppliedChipEntry {
interface VendorAppliedFiltersBarProps { interface VendorAppliedFiltersBarProps {
chips: AppliedChipEntry[]; chips: AppliedChipEntry[];
resultCount: number;
onClearAll: () => void; onClearAll: () => void;
} }
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { export function VendorAppliedFiltersBar({
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">
<Typography variant="caption" sx={{ color: "text.secondary" }}> <Text variant="caption" tone="muted">
Applied: {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
</Typography> </Text>
{chips.map((chip) => ( {chips.map((chip) => (
<Chip <Chip
key={chip.key} key={chip.key}
@ -149,23 +141,31 @@ 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)",
}} }}
> >
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
{totalCount} {isActive ? "active" : "inactive"}{" "} {totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "vendor entry" : "vendor entries"} {totalCount === 1 ? "technician" : "technicians"}
{isFiltered && " · filtered"} {isFiltered && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`} </Text>
</Typography> <Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
<TablePagination {activeCount} active · {inactiveCount} inactive
component="div" </Text>
count={totalCount} {totalCount > pageSize && (
page={page} <TablePagination
onPageChange={(_e, nextPage) => onPageChange(nextPage)} component="div"
rowsPerPage={pageSize} count={totalCount}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} page={page}
rowsPerPageOptions={[12, 24, 48]} onPageChange={(_e, nextPage) => onPageChange(nextPage)}
/> rowsPerPage={pageSize}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
rowsPerPageOptions={[24, 48, 96]}
/>
)}
</Stack> </Stack>
); );
} }

View file

@ -1,11 +1,10 @@
import { type KeyboardEvent, type MouseEvent } from "react"; import { type KeyboardEvent, type MouseEvent } from "react";
import { useNavigate } from "react-router"; import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
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,
Chip, Box,
CircularProgress, CircularProgress,
IconButton, IconButton,
Link, Link,
@ -18,8 +17,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 = [
@ -30,9 +29,6 @@ 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 {
@ -54,19 +50,34 @@ 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;
onOpenDeactivation: (row: VendorListItem) => void; onOpenEdit: (row: VendorListItem) => void;
} }
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { function VendorTableRow({ row, onOpenDetail, onOpenEdit }: 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) => {
@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<TableCell> <TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography> <Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.companyName || "—"}
</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}> <Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Avatar <Avatar
sx={{ sx={{
width: 28, width: 28,
height: 28, height: 28,
fontSize: 12, backgroundColor: "var(--color-bg-muted)",
backgroundColor: "var(--accent, #1976d2)", color: "var(--color-primary)",
fontSize: 10.5,
fontWeight: 600,
}} }}
> >
{getInitials(row.contactName)} {getInitials(row.contactName)}
</Avatar> </Avatar>
<Typography variant="body2">{row.contactName || "—"}</Typography> <Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.contactName || "—"}
</Text>
</Stack> </Stack>
</TableCell> </TableCell>
<TableCell>{locationText}</TableCell> <TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
<TableCell> <TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{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 && <Chip size="small" label={`+${extraTrades}`} />} {extraTrades > 0 && (
<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> <TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{row.phone ? ( {row.phone ? (
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}> <Link
href={`tel:${row.phone}`}
underline="hover"
color="inherit"
onClick={stopPropagation}
>
{row.phone} {row.phone}
</Link> </Link>
) : ( ) : (
@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
</TableCell> </TableCell>
<TableCell> <TableCell>
{row.email ? ( {row.email ? (
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}> <Link
href={`mailto:${row.email}`}
underline="hover"
sx={{ color: "#6b7a96" }}
onClick={stopPropagation}
>
{row.email} {row.email}
</Link> </Link>
) : ( ) : (
"—" "—"
)} )}
</TableCell> </TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell> <TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell> <TableCell>
<Chip <VendorStatus isActive={row.isActive} />
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="right">
<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}
sx={{ alignItems: "center", justifyContent: "center" }} className="vendor-row-actions"
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();
navigate( onOpenEdit(row);
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>
@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
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;
onOpenDeactivation: (row: VendorListItem) => void; onOpenEdit: (row: VendorListItem) => void;
} }
export function VendorsTable({ export function VendorsTable({
rows, rows,
isActive,
isFiltered,
isLoading, isLoading,
showError, showError,
onOpenDetail, onOpenDetail,
onOpenDeactivation, onOpenEdit,
}: 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 component={Paper} variant="outlined" sx={{ overflowX: "auto" }}> <TableContainer
<Table size="small" sx={{ minWidth: 960 }}> component={Paper}
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 <TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
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={TABLE_COLUMNS.length} align="center" className="py-10"> <TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} /> <CircularProgress size={28} />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : showEmptyRow ? ( ) : showEmptyRow ? (
<TableRow> <TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10"> <TableCell colSpan={columnCount} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}> <Stack spacing={1} sx={{ alignItems: "center" }}>
No vendors match the current filters. <SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
</Typography> <Text variant="description" className="font-semibold">
{`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>
) : ( ) : (
@ -252,7 +338,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}
onOpenDeactivation={onOpenDeactivation} onOpenEdit={onOpenEdit}
/> />
)) ))
)} )}

View file

@ -23,16 +23,91 @@ 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) => chips.push({ category: "companies", value, label: value })); filters.companies.forEach((value) =>
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); chips.push({ category: "companies", value, label: `Company: ${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({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), chips.push({
category: "jobBuckets",
value,
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
}),
); );
return chips; return chips;
} }
@ -42,7 +117,12 @@ 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 deactivation = useVendorDeactivation(); const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
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);
@ -61,7 +141,7 @@ export default function VendorsListPage() {
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
return ( return (
<Box className="flex flex-col gap-4 p-4"> <Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
<VendorsListToolbar <VendorsListToolbar
globalCount={activeCount + inactiveCount} globalCount={activeCount + inactiveCount}
activeCount={activeCount} activeCount={activeCount}
@ -74,18 +154,15 @@ export default function VendorsListPage() {
onAddVendor={() => setCreateOpen(true)} onAddVendor={() => setCreateOpen(true)}
/> />
<Tabs <VendorStatusTabs
value={filters.isActive ? "active" : "inactive"} isActive={filters.isActive}
onChange={(_event, value: "active" | "inactive") => activeCount={activeCount}
filters.setIsActiveWithReset(value === "active") inactiveCount={inactiveCount}
} 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,
@ -102,10 +179,18 @@ 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={setDetailTarget} onOpenDetail={(row) => {
onOpenDeactivation={deactivation.open} setDetailStartsInEdit(false);
setDetailTarget(row);
}}
onOpenEdit={(row) => {
setDetailStartsInEdit(true);
setDetailTarget(row);
}}
/> />
<VendorsListFooter <VendorsListFooter
@ -133,7 +218,9 @@ export default function VendorsListPage() {
<VendorDetailDrawer <VendorDetailDrawer
open={Boolean(detailTarget)} open={Boolean(detailTarget)}
vendor={detailTarget} vendor={detailTarget}
onClose={() => setDetailTarget(null)} startInEdit={detailStartsInEdit}
onRequestDeactivation={deactivation.open}
onClose={closeDetail}
/> />
<VendorDeactivationDialog <VendorDeactivationDialog

View file

@ -0,0 +1,45 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type DuplicateWoNumberDialogProps = {
open: boolean;
existing: WorkOrderTableRow | null;
onDismiss: () => void;
onViewExisting: (row: WorkOrderTableRow) => void;
};
export function DuplicateWoNumberDialog({
open,
existing,
onDismiss,
onViewExisting,
}: DuplicateWoNumberDialogProps) {
const woNumber = existing?.woNumber ?? "";
return (
<Dialog open={open} onClose={onDismiss} aria-labelledby="duplicate-wo-number-dialog-title">
<DialogTitle id="duplicate-wo-number-dialog-title">Duplicate Work Order Number</DialogTitle>
<DialogContent>
<Text variant="body">
Work order number {woNumber} is already assigned to another work order. View the existing
record or dismiss to keep editing.
</Text>
</DialogContent>
<DialogActions>
<Button onClick={onDismiss}>Dismiss</Button>
<Button
variant="contained"
disabled={existing == null}
onClick={() => {
if (existing != null) {
onViewExisting(existing);
}
}}
>
View existing
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -12,12 +12,16 @@ export function buildInlineDraftCreateInput(
const location = sites.find((s) => String(s.id) === String(draft.locationId)); const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || ""; const locationName = draft.location || draft.site || location?.name || "";
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
return { return {
locationId: String(draft.locationId || ""), locationId: String(draft.locationId || ""),
locationName, locationName,
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 || "PM",
severity: draft.severity,
assigneeId: draft.dispatcherId, assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "", assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn, scheduledOn: draft.scheduledOn,
@ -35,7 +39,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName, pocName: draft.pocName,
pocPhone: draft.pocPhone, pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes, pocNotes: draft.pocNotes,
scheduleWeekOnly: draft.scheduleWeekOnly, scheduleWeekOnly,
targetWeek: draft.targetWeek || "", targetWeek: draft.targetWeek || "",
}; };
} }

View file

@ -15,6 +15,8 @@ type DateCellProps = {
targetWeek?: string; targetWeek?: string;
onSaveWeek?: (weekStart: string) => void; onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date"; defaultMode?: "week" | "date";
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
onClear?: () => void;
}; };
export function DateCell({ export function DateCell({
@ -26,6 +28,7 @@ export function DateCell({
targetWeek = "", targetWeek = "",
onSaveWeek, onSaveWeek,
defaultMode, defaultMode,
onClear,
}: DateCellProps) { }: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">( const [cellMode, setCellMode] = useState<"week" | "date">(
@ -132,6 +135,21 @@ export function DateCell({
}} }}
/> />
)} )}
{Boolean(onClear) && (
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="text-[11.5px] font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
× Clear
</button>
</div>
)}
</Popover> </Popover>
</> </>
); );

View file

@ -1,43 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { HighlightText } from "./highlight-text"; import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = { type EditableWONumberCellProps = {
value: string; value: string;
rescheduleCount: number; rescheduleCount: number;
carriedOver?: number; carriedOver?: number;
q?: string; q?: string;
currentId: string | number; currentId: string | number;
allOrders: WorkOrderTableRow[]; allOrders: WorkOrderTableRow[];
onSave: (v: string) => void; onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
function CounterPill({ label, count }: { label: string; count: number }) { function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) { if (count < 2) return null;
return null;
}
return ( return (
<span <span
title={label} title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold" className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{ style={{
background: "var(--color-bg-muted)", background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)", color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
}} }}
> >
@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) {
); );
} }
type WoNumberEditorProps = {
draft: string;
invalid: boolean;
isChecking: boolean;
onDraftChange: (value: string) => void;
onCommit: () => void;
onCancel: () => void;
onBlur: () => void;
};
function WoNumberEditor({
draft,
invalid,
isChecking,
onDraftChange,
onCommit,
onCancel,
onBlur,
}: WoNumberEditorProps) {
return (
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(e) => onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
onBlur={onBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
);
}
async function resolveDuplicateWoNumber(
draft: string,
allOrders: WorkOrderTableRow[],
currentId: string | number,
) {
const local = findDuplicateWoNumberRow(draft, allOrders, currentId);
const remote = local ?? (await workOrdersApi.findByWoNumber(draft));
return remote && String(remote.id) !== String(currentId) ? remote : undefined;
}
export function EditableWONumberCell({ export function EditableWONumberCell({
value, value,
rescheduleCount, rescheduleCount,
carriedOver = 0, carriedOver = 0,
q, q,
currentId, currentId,
allOrders, allOrders,
onSave, onSave,
onDuplicateFound, onDuplicateFound,
}: EditableWONumberCellProps) { }: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => { useEffect(() => {
if (!editing) { if (!editing) setDraft(value);
setDraft(value);
}
}, [value, editing]); }, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => { const commit = () => {
if (draft === value) { if (draft === value || isChecking) {
setEditing(false); setEditing(false);
return; return;
} }
if (draft.length !== 11) { if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits"); toast("Work order number must be exactly 11 digits");
return; return;
} }
setIsChecking(true);
const duplicate = findDuplicate(draft); void (async () => {
try {
if (duplicate) { const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
onDuplicateFound?.(duplicate); if (duplicate) {
if (onDuplicateFound) {
setDraft(value); onDuplicateFound(duplicate);
} else {
setEditing(false); toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
}
return; setDraft(value);
} return;
}
onSave(draft); onSave(draft);
} catch {
setEditing(false); toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsChecking(false);
setEditing(false);
}
})();
}; };
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setEditing(false); setEditing(false);
}; };
const handleBlur = () => { const handleBlur = () => {
if (isChecking) return;
if (draft !== value && draft.length === 11) { if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft); commit();
return;
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
} }
setEditing(false); setEditing(false);
}; };
@ -137,75 +169,34 @@ export function EditableWONumberCell({
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{editing ? ( {editing ? (
<input <WoNumberEditor
autoFocus draft={draft}
inputMode="numeric" invalid={invalid}
value={draft} isChecking={isChecking}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} onDraftChange={setDraft}
onFocus={(e) => e.target.select()} onCommit={commit}
onClick={(e) => e.stopPropagation()} onCancel={cancel}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={handleBlur} onBlur={handleBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/> />
) : ( ) : (
<span <span
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setEditing(true); setEditing(true);
}} }}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{ style={{
fontSize: 13, fontSize: 13,
fontWeight: 700, fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)", color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4, borderRadius: 4,
padding: "2px 4px", padding: "2px 4px",
}} }}
> >
{value ? <HighlightText text={value} q={q} /> : "-"} {value ? <HighlightText text={value} q={q} /> : "-"}
</span> </span>
)} )}
<CounterPill label="Reschedules" count={rescheduleCount} /> <CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} /> <CounterPill label="Carried over" count={carriedOver} />
</div> </div>
); );

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

@ -17,6 +17,8 @@ type SiteCellProps = {
pocNotes?: string; pocNotes?: string;
sites: LocationOption[]; sites: LocationOption[];
q?: string; q?: string;
/** Inline create: editable POC + Site/POC required to confirm the modal. */
createMode?: boolean;
onSave: (patch: SitePatch) => void; onSave: (patch: SitePatch) => void;
}; };
@ -31,6 +33,7 @@ export function SiteCell({
pocNotes, pocNotes,
sites, sites,
q, q,
createMode = false,
onSave, onSave,
}: SiteCellProps) { }: SiteCellProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -70,7 +73,15 @@ export function SiteCell({
)} )}
</span> </span>
) : ( ) : (
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span> <span className="text-[12.5px] text-[var(--color-text-muted)]">
{createMode ? (
<>
Site <span style={{ color: "var(--destructive)" }}>*</span>
</>
) : (
"—"
)}
</span>
)} )}
<HoverChevron /> <HoverChevron />
</button> </button>
@ -83,6 +94,7 @@ export function SiteCell({
pocPhone={pocPhone} pocPhone={pocPhone}
pocNotes={pocNotes} pocNotes={pocNotes}
sites={sites} sites={sites}
createMode={createMode}
onSave={onSave} onSave={onSave}
/> />
</> </>

View file

@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
pocName: string; pocName: string;
pocPhone: string; pocPhone: string;
pocNotes: string; pocNotes: string;
editable?: boolean;
showErrors?: boolean;
onPocNameChange?: (value: string) => void;
onPocPhoneChange?: (value: string) => void;
onPocNotesChange?: (value: string) => void;
}; };
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) { export function SiteDialogPocFields({
pocName,
pocPhone,
pocNotes,
editable = false,
showErrors = false,
onPocNameChange,
onPocPhoneChange,
onPocNotesChange,
}: SiteDialogPocFieldsProps) {
const nameMissing = showErrors && !pocName.trim();
const phoneMissing = showErrors && !pocPhone.trim();
return ( return (
<div className="pt-1"> <div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel> <FieldLabel>Point of Contact</FieldLabel>
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}> <Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Shown from site/work order records. Board edit saves site code only. {editable
? "Auto-filled from records — edit if outdated"
: "Shown from site/work order records. Board edit saves site code only."}
</Text> </Text>
<div className="space-y-2"> <div className="space-y-2">
<input <div>
value={pocName} <input
readOnly value={pocName}
placeholder="POC name" readOnly={!editable}
className={DLG_INPUT_CLS} placeholder="POC name"
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }} className={DLG_INPUT_CLS}
/> style={{
<input ...DLG_INPUT_STYLE,
value={pocPhone} opacity: editable ? 1 : 0.85,
readOnly borderColor: nameMissing ? "var(--destructive)" : undefined,
placeholder="POC phone" }}
className={DLG_INPUT_CLS} onChange={(e) => onPocNameChange?.(e.target.value)}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }} />
/> <Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
Contact name is required
</Text>
</div>
<div>
<input
value={pocPhone}
readOnly={!editable}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: phoneMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocPhoneChange?.(e.target.value)}
/>
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
Contact phone is required
</Text>
</div>
<textarea <textarea
value={pocNotes} value={pocNotes}
readOnly readOnly={!editable}
placeholder="Notes…" placeholder="Notes…"
rows={2} rows={2}
className={`${DLG_INPUT_CLS} resize-none`} className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }} style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
onChange={(e) => onPocNotesChange?.(e.target.value)}
/> />
</div> </div>
</div> </div>

View file

@ -1,30 +1,20 @@
import { useEffect, useState, type MouseEvent } from "react"; import type { MouseEvent } from "react";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel"; import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields"; import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
import {
useSiteDialogState,
type SitePatch,
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
export type SitePatch = { export type { SitePatch };
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type SiteDialogProps = { type SiteDialogProps = {
open: boolean; open: boolean;
@ -35,6 +25,7 @@ type SiteDialogProps = {
pocPhone?: string; pocPhone?: string;
pocNotes?: string; pocNotes?: string;
sites: LocationOption[]; sites: LocationOption[];
createMode?: boolean;
onSave: (patch: SitePatch) => void; onSave: (patch: SitePatch) => void;
}; };
@ -47,62 +38,26 @@ export function SiteDialog({
pocPhone = "", pocPhone = "",
pocNotes = "", pocNotes = "",
sites, sites,
createMode = false,
onSave, onSave,
}: SiteDialogProps) { }: SiteDialogProps) {
const [code, setCode] = useState(value); const s = useSiteDialogState({
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites)); open,
const [pn, setPn] = useState(pocName); onOpenChange,
const [pp, setPp] = useState(pocPhone); value,
const [notes, setNotes] = useState(pocNotes); locationId,
const [pocFilledFor, setPocFilledFor] = useState(""); pocName,
pocPhone,
const { pocNotes,
data: locationDetail, sites,
isLoading: locationDetailLoading, createMode,
isError: locationDetailError, onSave,
} = useLocationDetail(open && locId ? locId : undefined); });
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
useEffect(() => {
if (!open) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
setPn(locationDetail.contact ?? "");
setPp(locationDetail.phone ?? "");
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={() => onOpenChange(false)} onClose={s.close}
maxWidth={false} maxWidth={false}
slotProps={{ slotProps={{
...dialogBlurBackdropSlotProps, ...dialogBlurBackdropSlotProps,
@ -125,7 +80,7 @@ export function SiteDialog({
<div> <div>
<FieldLabel>Site</FieldLabel> <FieldLabel>Site</FieldLabel>
<WizardFieldSelect <WizardFieldSelect
value={locId} value={s.locId}
placeholder="Select site …" placeholder="Select site …"
icon={ icon={
<MapPin <MapPin
@ -139,45 +94,45 @@ export function SiteDialog({
key: String(site.id), key: String(site.id),
label: site.name, label: site.name,
}))} }))}
onPick={handlePick} onPick={s.handlePick}
/> />
<Text
as="p"
variant="error"
when={Boolean(createMode && s.showErrors && s.siteMissing)}
className="mt-1 text-[11px]"
>
Site is required
</Text>
</div> </div>
{Boolean(locId) && ( {Boolean(s.locId) && (
<SiteDialogDetailPanel <SiteDialogDetailPanel
locationDetail={locationDetail} locationDetail={s.locationDetail}
locationDetailLoading={locationDetailLoading} locationDetailLoading={s.locationDetailLoading}
locationDetailError={locationDetailError} locationDetailError={s.locationDetailError}
addressPreview={addressPreview} addressPreview={s.addressPreview}
fallbackLabel={selected?.name || code || "—"} fallbackLabel={s.selected?.name || s.code || "—"}
/> />
)} )}
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} /> <SiteDialogPocFields
pocName={s.pn}
pocPhone={s.pp}
pocNotes={s.notes}
editable={createMode}
showErrors={createMode && s.showErrors}
onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes}
/>
</div> </div>
<div className="mt-4 flex items-center justify-end gap-2"> <div className="mt-4 flex items-center justify-end gap-2">
<Button <Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button <Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
}}
>
Save Save
</Button> </Button>
</div> </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

@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import {
extractLocationLabel,
resolveLocationId,
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type UseSiteDialogStateArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
sites: LocationOption[];
createMode: boolean;
onSave: (patch: SitePatch) => void;
};
export function useSiteDialogState({
open,
onOpenChange,
value,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
createMode,
onSave,
}: UseSiteDialogStateArgs) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const [showErrors, setShowErrors] = useState(false);
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
const siteMissing = !locId;
const pocMissing = !pn.trim() || !pp.trim();
const canConfirm = !siteMissing && (!createMode || !pocMissing);
useEffect(() => {
if (!open) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setShowErrors(false);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
// Only autofill empty fields so typed createMode POC edits are not overwritten.
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
const siteChanged = id !== locId;
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (siteChanged) {
setPn("");
setPp("");
setPocFilledFor("");
} else if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const attemptSave = () => {
if (createMode && !canConfirm) {
setShowErrors(true);
return;
}
onSave({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
};
return {
locId,
pn,
pp,
notes,
showErrors,
locationDetail,
locationDetailLoading,
locationDetailError,
selected,
addressPreview,
siteMissing,
canConfirm,
code,
handlePick,
attemptSave,
setPn,
setPp,
setNotes,
close: () => onOpenChange(false),
};
}

View file

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Text } from "@/components/ui/text";
type InlineCreateFieldProps = {
showError?: boolean;
error?: string;
children: ReactNode;
};
/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
return (
<div className="flex w-full min-w-0 flex-col gap-1">
<div
className="flex w-full min-w-0 items-center"
style={{
minHeight: 34,
borderRadius: 6,
padding: "0 8px",
background: "var(--color-bg-surface)",
border: `1px solid ${showError ? "var(--destructive)" : "var(--color-border)"}`,
boxShadow: showError ? "0 0 0 1px var(--destructive)" : "none",
}}
>
{children}
</div>
<Text as="p" variant="error" when={Boolean(showError && error)} className="m-0 text-[11px]">
{error}
</Text>
</div>
);
}

View file

@ -0,0 +1,58 @@
import { useState } from "react";
import { toast } from "react-toastify";
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
type UseWoTableInlineCreateArgs = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
users: WorkOrderUser[];
sites: LocationOption[];
onDuplicateFound: (row: WorkOrderTableRow) => void;
};
export function useWoTableInlineCreate({
tableData,
users,
sites,
onDuplicateFound,
}: UseWoTableInlineCreateArgs) {
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
});
const handleInlineSave = () => {
void (async () => {
const draftSnapshot = tableData.draft;
const woNumber = draftSnapshot.woNumber.trim();
if (woNumber) {
setIsCheckingInlineDuplicate(true);
try {
const duplicate =
findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
(await workOrdersApi.findByWoNumber(woNumber));
if (duplicate && String(duplicate.id) !== "draft") {
onDuplicateFound(duplicate);
return;
}
} catch {
toast.error("Unable to verify the work order number. Please try again.");
return;
} finally {
setIsCheckingInlineDuplicate(false);
}
}
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
})();
};
return {
handleInlineSave,
saving: createMutation.isPending || isCheckingInlineDuplicate,
};
}

View file

@ -19,6 +19,7 @@ type UseWoTableRowHandlersOptions = {
onOpenMedia: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void; onScheduleHighlight: (id: string | number | null) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
export function useWoTableRowHandlers({ export function useWoTableRowHandlers({
@ -30,6 +31,7 @@ export function useWoTableRowHandlers({
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight, onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers { }: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const { const {
dragState: dragStateRef, dragState: dragStateRef,
@ -97,8 +99,7 @@ export function useWoTableRowHandlers({
}); });
}, },
onDuplicateFound: (existing: WorkOrderTableRow) => { onDuplicateFound: (existing: WorkOrderTableRow) => {
tableData.setSelectedId(existing.id); onDuplicateFound?.(existing);
onOpenSlideOver(existing, false);
}, },
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false), onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
@ -138,6 +139,7 @@ export function useWoTableRowHandlers({
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight, onScheduleHighlight,
onDuplicateFound,
], ],
); );
} }

View file

@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 }, { key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 }, { key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 }, { key: "doc", label: "COMP DOC", w: 84 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 }, { key: "actions", label: "", w: 100 },
] as const; ] as const;
export const WO_TABLE_MIN_WIDTH = 1504; export const WO_TABLE_MIN_WIDTH = 1634;
export const WO_TABLE_ROW_HEIGHT = 52; export const WO_TABLE_ROW_HEIGHT = 52;

View file

@ -0,0 +1,88 @@
import type { RefObject } from "react";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell";
import { InlineCreateField } from "./inline-create-field";
type WoTableInlineIdentityCellsProps = {
draft: WorkOrderTableDraft;
sites: LocationOption[];
sitePlace?: { city: string; state: string };
allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
siteShowError: boolean;
siteError: string;
woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function WoTableInlineIdentityCells({
draft,
sites,
sitePlace,
allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch,
onDuplicateFound,
}: WoTableInlineIdentityCellsProps) {
return (
<>
<td
ref={siteCellRef}
className="group/cell"
style={{ padding: "8px 6px 0", verticalAlign: "top" }}
>
<InlineCreateField showError={siteShowError} error={siteError}>
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
createMode
onSave={(p) =>
onPatch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</InlineCreateField>
</td>
<td
ref={woCellRef}
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
>
<InlineCreateField showError={woShowError} error="Work order number is required">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/>
</InlineCreateField>
</td>
</>
);
}

View file

@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
type WoTableInlineRowActionsProps = { type WoTableInlineRowActionsProps = {
canSave: boolean; canSave: boolean;
saving: boolean; saving: boolean;
saveHint: string;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
}; };
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
export function WoTableInlineRowActions({ export function WoTableInlineRowActions({
canSave, canSave,
saving, saving,
saveHint,
onSave, onSave,
onCancel, onCancel,
}: WoTableInlineRowActionsProps) { }: WoTableInlineRowActionsProps) {
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
<button <button
type="button" type="button"
onClick={onSave} onClick={onSave}
disabled={!canSave} disabled={saving}
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"} title={saveHint}
aria-label={saving ? "Saving work order" : "Save work order"} aria-label={saving ? "Saving work order" : "Save work order"}
className="rounded p-1 transition-colors disabled:opacity-30" className="rounded p-1 transition-colors disabled:opacity-30"
style={{ color: "var(--color-primary)" }} style={{
color: "var(--color-primary)",
...(canSave || saving ? {} : { opacity: 0.5, cursor: "not-allowed" }),
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (canSave) { if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)"; (e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";

View file

@ -1,20 +1,23 @@
import type { RefObject } from "react";
import type { import type {
LocationOption, LocationOption,
VendorDropdownItem, VendorDropdownItem,
WorkOrderUser, WorkOrderUser,
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { AssignedToCell } from "./cells/assigned-to-cell"; import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell"; import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell"; import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell"; import { DueDateCell } from "./cells/due-date-cell";
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { PMTypeCell } from "./cells/pm-type-cell"; import { PMTypeCell } from "./cells/pm-type-cell";
import { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell"; import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell"; import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
import { toInlineDraftRow } from "./to-inline-draft-row"; import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
type WoTableInlineRowCellsProps = { type WoTableInlineRowCellsProps = {
draft: WorkOrderTableDraft; draft: WorkOrderTableDraft;
@ -25,9 +28,41 @@ type WoTableInlineRowCellsProps = {
vendors: VendorDropdownItem[]; vendors: VendorDropdownItem[];
pmTypes: string[]; pmTypes: string[];
allRows: { id: string | number; woNumber: string }[]; allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
siteShowError: boolean;
siteError: string;
woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void; onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
function InlineTypeSeverityCell({
draft,
onPatch,
}: {
draft: WorkOrderTableDraft;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
return (
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
/>
</td>
);
}
function InlineMutedDashCell() {
return (
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
);
}
export function WoTableInlineRowCells({ export function WoTableInlineRowCells({
draft, draft,
weekDefault, weekDefault,
@ -37,49 +72,34 @@ export function WoTableInlineRowCells({
vendors, vendors,
pmTypes, pmTypes,
allRows, allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch, onPatch,
onDuplicateFound,
}: WoTableInlineRowCellsProps) { }: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft); const draftRow = toInlineDraftRow(draft);
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return ( return (
<> <>
<td className="group/cell" style={{ padding: "0 14px" }}> <WoTableInlineIdentityCells
<SiteCell draft={draft}
value={draft.site} sites={sites}
location={draft.location} sitePlace={sitePlace}
locationId={draft.locationId} allRows={allRows}
city={sitePlace?.city} siteCellRef={siteCellRef}
state={sitePlace?.state} woCellRef={woCellRef}
pocName={draft.pocName} siteShowError={siteShowError}
pocPhone={draft.pocPhone} siteError={siteError}
pocNotes={draft.pocNotes} woShowError={woShowError}
sites={sites} onPatch={onPatch}
onSave={(p) => onDuplicateFound={onDuplicateFound}
onPatch({ />
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<td style={{ padding: "0 14px" }}> <InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={0}
currentId="draft"
allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })}
/>
</td>
<td className="group/cell" style={{ padding: "0 14px" }}>
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
</td>
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell <AssignedToCell
@ -98,11 +118,15 @@ export function WoTableInlineRowCells({
allowWeekMode allowWeekMode
targetWeek={draft.targetWeek} targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"} defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })} onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) => onSaveWeek={(week) =>
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true }) onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
} }
onClear={
hasSchedule
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
: undefined
}
/> />
</td> </td>
@ -170,9 +194,8 @@ export function WoTableInlineRowCells({
/> />
</td> </td>
<td style={{ padding: "0 14px" }}> <InlineMutedDashCell />
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span> <InlineMutedDashCell />
</td>
</> </>
); );
} }

View file

@ -1,3 +1,4 @@
import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react"; import { Flag } from "lucide-react";
import type { import type {
@ -5,10 +6,17 @@ import type {
VendorDropdownItem, VendorDropdownItem,
WorkOrderUser, WorkOrderUser,
} from "@/domain/work-orders/types/work-order"; } from "@/domain/work-orders/types/work-order";
import type {
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row"; WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
import {
draftHasScheduleDate,
getInlineCreateMissing,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells"; import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@ -16,72 +24,88 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = { type WoTableInlineRowProps = {
draft: WorkOrderTableDraft; draft: WorkOrderTableDraft;
draftDayKey: string; draftDayKey: string;
users: WorkOrderUser[]; users: WorkOrderUser[];
sites: LocationOption[]; sites: LocationOption[];
sitePlace?: { city: string; state: string }; sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[]; vendors: VendorDropdownItem[];
pmTypes: string[]; pmTypes: string[];
allRows: { id: string | number; woNumber: string }[]; allRows: { id: string | number; woNumber: string }[];
saving?: boolean; saving?: boolean;
onChange: (draft: WorkOrderTableDraft) => void; onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
const dated = draftHasScheduleDate(next);
if (dated && PRE_SCHEDULE.has(next.status)) {
return { ...next, status: "Scheduled" };
}
if (!dated && next.status === "Scheduled") {
return { ...next, status: "Pending" };
}
return next;
}
export function WoTableInlineRow({ export function WoTableInlineRow({
draft, draft,
draftDayKey, draftDayKey,
users, users,
sites, sites,
sitePlace, sitePlace,
vendors, vendors,
pmTypes, pmTypes,
allRows, allRows,
saving = false, saving = false,
onChange, onChange,
onSave, onSave,
onCancel, onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) { }: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const missing = getInlineCreateMissing(draft);
const canSave = !saving && missing.length === 0;
const [showErrors, setShowErrors] = useState(false);
const siteCellRef = useRef<HTMLTableCellElement>(null);
const woCellRef = useRef<HTMLTableCellElement>(null);
const canSave = const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn); const siteShowError = showErrors && siteCellMissing;
const woShowError = showErrors && missing.includes("woNumber");
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p }); const patch = (p: Partial<WorkOrderTableDraft>) =>
onChange(withScheduleStatusSync({ ...draft, ...p }));
const focusCell = (r: RefObject<HTMLTableCellElement | null>) =>
r.current?.querySelector<HTMLElement>("input, button, [tabindex]")?.focus();
const attemptSave = () => {
if (saving) {
return;
}
if (canSave) {
onSave();
return;
}
setShowErrors(true);
if (siteCellMissing) {
focusCell(siteCellRef);
} else if (missing.includes("woNumber")) {
focusCell(woCellRef);
}
};
return ( return (
<tr <tr
aria-busy={saving || undefined} aria-busy={saving || undefined}
style={{ style={{
height: WO_TABLE_ROW_HEIGHT, height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)", borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)", background: "var(--color-primary-tint)",
opacity: saving ? 0.7 : 1, opacity: saving ? 0.7 : 1,
pointerEvents: saving ? "none" : undefined, pointerEvents: saving ? "none" : undefined,
@ -102,14 +126,21 @@ export function WoTableInlineRow({
vendors={vendors} vendors={vendors}
pmTypes={pmTypes} pmTypes={pmTypes}
allRows={allRows} allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={inlineCreateSiteError(missing)}
woShowError={woShowError}
onPatch={patch} onPatch={patch}
onDuplicateFound={onDuplicateFound}
/> />
<td style={{ padding: "0 4px" }}> <td style={{ padding: "0 4px" }}>
<WoTableInlineRowActions <WoTableInlineRowActions
canSave={canSave} canSave={canSave}
saving={saving} saving={saving}
onSave={onSave} saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
onSave={attemptSave}
onCancel={onCancel} onCancel={onCancel}
/> />
</td> </td>

View file

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

@ -3,6 +3,7 @@ import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { ApptTimeCell } from "./cells/appt-time-cell"; import { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell"; import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell"; import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell"; import { StatusCell } from "./cells/status-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
@ -102,6 +103,17 @@ export function WoTableRowServiceCells({
onOpenMedia={() => handlers.onOpenMedia(row)} onOpenMedia={() => handlers.onOpenMedia(row)}
/> />
</td> </td>
<td
className="group/cell"
style={{ padding: "0 14px", ...mutationCellStyle }}
onClick={(e) => e.stopPropagation()}
>
<DateCell
value={row.completedDate}
q={search}
onSave={(date) => onPatchRow({ completedDate: date })}
/>
</td>
</> </>
); );
} }

View file

@ -9,11 +9,11 @@ import type {
WorkOrderTablePatch, WorkOrderTablePatch,
WorkOrderTableRow, WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row"; } from "@/domain/work-orders/types/work-order-table-row";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers"; import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
import { WoTableBody } from "./wo-table-body"; import { WoTableBody } from "./wo-table-body";
import { WoTableHead } from "./wo-table-head"; import { WoTableHead } from "./wo-table-head";
@ -55,10 +55,14 @@ export function WoTable({
onPatch, onPatch,
}: WoTableProps) { }: WoTableProps) {
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null); const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const { data: locationSites = [] } = useLocationSites(); const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]); const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => { const { handleInlineSave, saving } = useWoTableInlineCreate({
tableData.cancelDraft(); tableData,
users,
sites,
onDuplicateFound: setDuplicateRow,
}); });
const handlers = useWoTableRowHandlers({ const handlers = useWoTableRowHandlers({
@ -70,6 +74,7 @@ export function WoTable({
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight: setDueDateHighlightId, onScheduleHighlight: setDueDateHighlightId,
onDuplicateFound: setDuplicateRow,
}); });
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -107,12 +112,11 @@ export function WoTable({
vendors={vendors} vendors={vendors}
pmTypes={pmTypes} pmTypes={pmTypes}
allRows={tableData.rows} allRows={tableData.rows}
saving={createMutation.isPending} saving={saving}
onChange={tableData.setDraft} onChange={tableData.setDraft}
onSave={() => onSave={handleInlineSave}
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
}
onCancel={tableData.cancelDraft} onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/> />
); );
@ -151,21 +155,33 @@ export function WoTable({
} }
return ( return (
<WoTableShell> <>
<table <WoTableShell>
style={{ <table
width: "100%", style={{
minWidth: WO_TABLE_MIN_WIDTH, width: "100%",
borderCollapse: "collapse", minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
}}
>
<WoTableHead />
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
<DuplicateWoNumberDialog
open={duplicateRow != null}
existing={duplicateRow}
onDismiss={() => setDuplicateRow(null)}
onViewExisting={(row) => {
setDuplicateRow(null);
onOpenSlideOver(row, false);
}} }}
> />
<WoTableHead /> </>
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
); );
} }

View file

@ -1,90 +0,0 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
wizardOpen: boolean;
onWizardOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
sites: LocationOption[];
pmTypes: string[];
vendorTechs: string[];
advOpen: boolean;
onAdvOpenChange: (open: boolean) => void;
advInitial: WorkOrderAdvancedFilters;
onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
onClearAdvanced: () => void;
activeDocRow: WorkOrderTableRow | null;
onDocOpenChange: (open: boolean) => void;
completionDocs: Record<string, CompDocData>;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange: (date: string) => void | Promise<void>;
activeMediaRow: WorkOrderTableRow | null;
onMediaOpenChange: (open: boolean) => void;
};
export function WorkOrdersListOverlays({
wizardOpen,
onWizardOpenChange,
users,
sites,
pmTypes,
vendorTechs,
advOpen,
onAdvOpenChange,
advInitial,
onApplyAdvanced,
onClearAdvanced,
activeDocRow,
onDocOpenChange,
completionDocs,
onSaveCompDoc,
onDateCompletedChange,
activeMediaRow,
onMediaOpenChange,
}: WorkOrdersListOverlaysProps) {
return (
<>
<WorkOrdersAdvancedSearchSheet
open={advOpen}
onOpenChange={onAdvOpenChange}
initial={advInitial}
onApply={onApplyAdvanced}
onClearAll={onClearAdvanced}
sites={sites}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={onWizardOpenChange} users={users} />
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={onDocOpenChange}
data={completionDocs[String(activeDocRow.id)]}
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
// Notes/generatedAt stay client-local (no content endpoint on this branch).
onSave={onSaveCompDoc}
onDateCompletedChange={onDateCompletedChange}
/>
)}
{activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={onMediaOpenChange}
/>
)}
</>
);
}

View file

@ -0,0 +1,32 @@
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
rows: WorkOrderTableRow[];
onViewExisting: (row: WorkOrderTableRow) => void;
};
export function WorkOrdersListWizard({
open,
onOpenChange,
users,
rows,
onViewExisting,
}: WorkOrdersListWizardProps) {
return (
<NewWoWizard
open={open}
onOpenChange={onOpenChange}
users={users}
existingRows={rows}
onViewExistingWo={(row) => {
onOpenChange(false);
onViewExisting(row);
}}
/>
);
}

View file

@ -0,0 +1,113 @@
import { Link as RouterLink } from "react-router";
import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail";
import {
WORK_ORDER_STATUS_OPTIONS,
type WorkOrderStatus,
} from "@/domain/work-orders/types/work-order";
const STATUS_COLORS: Record<
string,
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
> = {
Open: "primary",
"In Progress": "info",
"On Hold": "warning",
Done: "success",
Cancelled: "default",
};
interface WorkOrderHeaderSectionProps {
wo: WorkOrderDetail;
statusAnchor: HTMLElement | null;
onStatusAnchorChange: (anchor: HTMLElement | null) => void;
onStatusChange: (status: WorkOrderStatus) => void;
onDispatch: () => void;
onEdit: () => void;
onBack: () => void;
}
export function WorkOrderHeaderSection({
wo,
statusAnchor,
onStatusAnchorChange,
onStatusChange,
onDispatch,
onEdit,
onBack,
}: WorkOrderHeaderSectionProps) {
return (
<>
<Breadcrumbs>
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography
sx={{
color: "text.primary",
}}
>
{wo.internalWONumber}
</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
justifyContent: "space-between",
alignItems: { xs: "stretch", md: "flex-start" },
}}
>
<Stack spacing={1}>
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => onStatusAnchorChange(event.currentTarget)}
className="w-fit cursor-pointer"
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => onStatusAnchorChange(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
onStatusAnchorChange(null);
if (status !== wo.status) {
onStatusChange(status);
}
}}
>
{status}
</MenuItem>
))}
</Menu>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{
flexWrap: "wrap",
}}
>
<Button variant="contained" color="success" onClick={onDispatch}>
Dispatch
</Button>
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
<Button variant="outlined" onClick={onBack}>
Back to List
</Button>
</Stack>
</Stack>
</>
);
}

View file

@ -1,169 +1,139 @@
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 { guardDialogClose } from "@/lib/dialog-guard"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
import { 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 { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header"; import { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header";
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 { useNewWoWizardController } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller";
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
type NewWoWizardProps = { type NewWoWizardProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
users: WorkOrderUser[]; users: WorkOrderUser[];
existingRows?: WorkOrderTableRow[];
onViewExistingWo?: (row: WorkOrderTableRow) => void;
}; };
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) { export function NewWoWizard({
const { open,
step, onOpenChange,
setStep, users,
draft, existingRows = [],
setDraft, onViewExistingWo,
setField, }: NewWoWizardProps) {
patchDraft: patchDraftOnly, const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
svcOpen,
setSvcOpen,
svcSearch,
setSvcSearch,
} = useNewWoWizardDraft(open);
const { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(draft.locationId || undefined);
const { data: vendors = [] } = useWorkOrderVendors(
draft.pm.trim() || undefined,
locationDetail?.zipCode,
);
const { notifyLocationChanged } = useWizardPocAutofill(
open,
draft.locationId,
locationDetail,
setDraft,
);
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
patchDraftOnly(patch);
if (patch.locationId !== undefined) {
notifyLocationChanged(patch.locationId);
}
};
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const handleCreate = () => createMutation.mutate(draft);
const isCreating = createMutation.isPending;
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
return ( return (
<Dialog <>
open={open} <Dialog
onClose={handleClose} open={open}
maxWidth={false} onClose={wizard.handleClose}
slotProps={{ maxWidth={false}
...dialogBlurBackdropSlotProps, slotProps={{
paper: { ...dialogBlurBackdropSlotProps,
className: "flex flex-col overflow-hidden p-0", paper: {
style: { className: "flex flex-col overflow-hidden p-0",
borderRadius: 12, style: {
width: 768, borderRadius: 12,
maxWidth: 768, width: 768,
height: 680, maxWidth: 768,
maxHeight: 680, height: 680,
pointerEvents: isCreating ? "none" : undefined, maxHeight: 680,
opacity: isCreating ? 0.85 : 1, pointerEvents: wizard.isCreating ? "none" : undefined,
opacity: wizard.isCreating ? 0.85 : 1,
},
}, },
},
}}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1" aria-busy={isCreating || undefined}>
<WizardSidebar step={step} draft={draft} />
<div
className="min-h-0 flex-1 overflow-y-auto"
style={{ padding: 32, background: "var(--color-bg-surface)" }}
>
<WizardStepHeader step={step} />
{step === 1 && (
<WizardStepTypeSchedule
draft={draft}
users={users}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
{step === 2 && (
<WizardStepLocationService
draft={draft}
locations={locations}
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
services={problems.map((p) => ({ id: p.id, value: p.value }))}
svcOpen={svcOpen}
onSvcOpenChange={setSvcOpen}
svcSearch={svcSearch}
onSvcSearchChange={setSvcSearch}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
{step === 3 && (
<WizardStepVendorTime
draft={draft}
vendors={vendors}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
</div>
</div>
<WizardFooter
step={step}
draft={draft}
onCancel={() => {
if (!isCreating) onOpenChange(false);
}} }}
onBack={() => setStep((s) => s - 1)} >
onContinue={() => setStep((s) => s + 1)} <span className="sr-only">Guided setup for creating a new work order</span>
onCreate={handleCreate}
isCreating={isCreating} <div className="flex min-h-0 flex-1" aria-busy={wizard.isCreating || undefined}>
<WizardSidebar step={wizard.step} draft={wizard.draft} />
<div
className="min-h-0 flex-1 overflow-y-auto"
style={{ padding: 32, background: "var(--color-bg-surface)" }}
>
<WizardStepHeader step={wizard.step} />
{wizard.step === 1 && (
<WizardStepTypeSchedule
draft={wizard.draft}
users={users}
showErrors={wizard.showErrors}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
onDuplicateWoNumber={wizard.handleDuplicateFound}
/>
)}
{wizard.step === 2 && (
<WizardStepLocationService
draft={wizard.draft}
locations={wizard.locations}
locationDetail={wizard.locationDetail}
locationDetailLoading={wizard.locationDetailLoading}
locationDetailError={wizard.locationDetailError}
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
showErrors={wizard.showErrors}
svcOpen={wizard.svcOpen}
onSvcOpenChange={wizard.setSvcOpen}
svcSearch={wizard.svcSearch}
onSvcSearchChange={wizard.setSvcSearch}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
/>
)}
{wizard.step === 3 && (
<WizardStepVendorTime
draft={wizard.draft}
vendors={wizard.vendors}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
/>
)}
</div>
</div>
<WizardFooter
step={wizard.step}
draft={wizard.draft}
onCancel={() => {
if (!wizard.isCreating) onOpenChange(false);
}}
onBack={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s - 1);
}}
onContinue={() => {
wizard.setShowErrors(false);
wizard.setStep((s) => s + 1);
}}
onAttemptContinue={() => wizard.setShowErrors(true)}
onCreate={wizard.handleCreate}
isCreating={wizard.isCreating}
/>
</Dialog>
<DuplicateWoNumberDialog
open={wizard.duplicateRow != null}
existing={wizard.duplicateRow}
onDismiss={() => wizard.setDuplicateRow(null)}
onViewExisting={(row) => {
wizard.setDuplicateRow(null);
onOpenChange(false);
onViewExistingWo?.(row);
}}
/> />
</Dialog> </>
); );
} }

View file

@ -0,0 +1,93 @@
import { useEffect } from "react";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
import { 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 { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
type UseNewWoWizardControllerArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
existingRows: WorkOrderTableRow[];
};
/** Wires draft state, lookups, POC autofill, and duplicate-WO guards for the create wizard. */
export function useNewWoWizardController({
open,
onOpenChange,
existingRows,
}: UseNewWoWizardControllerArgs) {
const draftState = useNewWoWizardDraft(open);
const { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(draftState.draft.locationId || undefined);
const { data: vendors = [] } = useWorkOrderVendors(
draftState.draft.pm.trim() || undefined,
locationDetail?.zipCode,
);
const { notifyLocationChanged } = useWizardPocAutofill(
open,
draftState.draft.locationId,
locationDetail,
draftState.setDraft,
);
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
draftState.patchDraft(patch);
if (patch.locationId !== undefined) {
notifyLocationChanged(patch.locationId);
}
};
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,
draft: draftState.draft,
existingRows,
createMutation,
});
useEffect(() => {
if (open) {
setDuplicateRow(null);
}
}, [open, setDuplicateRow]);
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
return {
...draftState,
patchDraft,
locations,
problems,
locationDetail,
locationDetailLoading,
locationDetailError,
vendors,
duplicateRow,
setDuplicateRow,
handleDuplicateFound,
handleCreate,
isCreating,
handleClose,
};
}

View file

@ -13,6 +13,7 @@ export function useNewWoWizardDraft(open: boolean) {
}); });
const [svcOpen, setSvcOpen] = useState(false); const [svcOpen, setSvcOpen] = useState(false);
const [svcSearch, setSvcSearch] = useState(""); const [svcSearch, setSvcSearch] = useState("");
const [showErrors, setShowErrors] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) {
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setSvcOpen(false); setSvcOpen(false);
setSvcSearch(""); setSvcSearch("");
setShowErrors(false);
} }
}, [open]); }, [open]);
@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) {
setSvcOpen, setSvcOpen,
svcSearch, svcSearch,
setSvcSearch, setSvcSearch,
showErrors,
setShowErrors,
}; };
} }

View file

@ -0,0 +1,140 @@
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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
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";
import { useWizardDuplicateActions } from "./use-wizard-duplicate-actions";
type UseNewWoWizardStateArgs = {
open: boolean;
users: WorkOrderUser[];
existingRows: WorkOrderTableRow[];
onOpenChange: (open: boolean) => void;
};
export function useNewWoWizardState({
open,
users,
existingRows,
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 { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,
draft,
existingRows,
createMutation,
});
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("");
setDuplicateRow(null);
}
}, [open, setDuplicateRow]);
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("");
}
};
return {
step,
setStep,
showErrors,
setShowErrors,
draft,
duplicateRow,
setDuplicateRow,
wizardUsers,
locations,
problems,
vendors,
locationDetail,
setField,
patchDraft,
handleDuplicateFound,
handleCreate,
isCreating,
};
}

View file

@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
type UseWizardDuplicateActionsArgs = {
open: boolean;
draft: WorkOrderWizardDraft;
existingRows: WorkOrderTableRow[];
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
export function useWizardDuplicateActions({
open,
draft,
existingRows,
createMutation,
}: UseWizardDuplicateActionsArgs) {
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const openRef = useRef(open);
const draftRef = useRef(draft);
useEffect(() => {
openRef.current = open;
}, [open]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const resolveDuplicate = async (woNumber: string) => {
const localDuplicate = findDuplicateWoNumberRow(woNumber, existingRows);
if (localDuplicate) {
return localDuplicate;
}
// The loaded board rows are only an immediate UX hint; the unfiltered API lookup is authoritative.
return workOrdersApi.findByWoNumber(woNumber);
};
const handleDuplicateFound = (woNumber: string) => {
void (async () => {
try {
const duplicate = await resolveDuplicate(woNumber);
// Ignore stale lookups after dismiss or after the user edits the WO#.
if (!openRef.current || woNumber !== draftRef.current.woNumber) {
return;
}
if (duplicate) {
setDuplicateRow(duplicate);
}
} catch {
if (!openRef.current) {
return;
}
toast.error("Unable to verify the work order number. Please try again.");
}
})();
};
const handleCreate = () => {
const woNumberAtSubmit = draft.woNumber;
void (async () => {
setIsCheckingDuplicate(true);
try {
const duplicate = await resolveDuplicate(woNumberAtSubmit);
// Ignore after dismiss or if the WO# changed while the request was in flight.
if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
return;
}
if (duplicate) {
setDuplicateRow(duplicate);
return;
}
createMutation.mutate(draftRef.current);
} catch {
if (!openRef.current) {
return;
}
toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsCheckingDuplicate(false);
}
})();
};
return {
duplicateRow,
setDuplicateRow,
handleDuplicateFound,
handleCreate,
isCreating: createMutation.isPending || isCheckingDuplicate,
};
}

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,31 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import { SeverityOptionsList } from "@/app/(protected)/workorders/_components/list/table/cells/severity-options-list";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardSeverityPickerProps = {
value: WorkOrderSeverity | null;
onSelect: (severity: WorkOrderSeverity) => void;
showError: boolean;
};
export function WizardSeverityPicker({ value, onSelect, showError }: WizardSeverityPickerProps) {
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>
<SeverityOptionsList value={value} onSelect={onSelect} />
{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>
);
}

View file

@ -4,17 +4,24 @@ import {
DLG_INPUT_CLS, DLG_INPUT_CLS,
DLG_INPUT_STYLE, DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; } from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepLocationPocFieldsProps = { type WizardStepLocationPocFieldsProps = {
draft: WorkOrderWizardDraft; draft: WorkOrderWizardDraft;
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
}; };
export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) { export function WizardStepLocationPocFields({
draft,
showErrors = false,
onUpdate,
}: WizardStepLocationPocFieldsProps) {
const pocIncomplete = !draft.pocName.trim() || !draft.pocPhone.trim();
return ( return (
<div> <div>
<WizSubLabel>Point of contact</WizSubLabel> <WizLabel required>Point of contact</WizLabel>
<Text <Text
as="p" as="p"
variant="description" variant="description"
@ -38,6 +45,11 @@ export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocat
style={DLG_INPUT_STYLE} style={DLG_INPUT_STYLE}
/> />
</div> </div>
{Boolean(showErrors && pocIncomplete) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
POC name and phone are required.
</Text>
)}
<textarea <textarea
value={draft.pocNotes} value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)} onChange={(e) => onUpdate("pocNotes", e.target.value)}

View file

@ -16,6 +16,7 @@ type WizardStepLocationServiceProps = {
locationDetailLoading?: boolean; locationDetailLoading?: boolean;
locationDetailError?: boolean; locationDetailError?: boolean;
services: ServiceOption[]; services: ServiceOption[];
showErrors?: boolean;
svcOpen: boolean; svcOpen: boolean;
onSvcOpenChange: (open: boolean) => void; onSvcOpenChange: (open: boolean) => void;
svcSearch: string; svcSearch: string;
@ -31,6 +32,7 @@ export function WizardStepLocationService({
locationDetailLoading, locationDetailLoading,
locationDetailError, locationDetailError,
services, services,
showErrors = false,
svcOpen, svcOpen,
onSvcOpenChange, onSvcOpenChange,
svcSearch, svcSearch,
@ -57,7 +59,7 @@ export function WizardStepLocationService({
locationDetailLoading={locationDetailLoading} locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError} locationDetailError={locationDetailError}
/> />
<WizardStepLocationPocFields draft={draft} onUpdate={onUpdate} /> <WizardStepLocationPocFields draft={draft} showErrors={showErrors} onUpdate={onUpdate} />
</> </>
)} )}

View file

@ -1,7 +1,16 @@
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 {
typeNeedsSeverity,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field"; import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
@ -15,6 +24,7 @@ import {
WizLabel, WizLabel,
WizLabelRow, WizLabelRow,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardSeverityPicker } from "@/app/(protected)/workorders/_components/wizard/wizard-severity-picker";
import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react"; import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react";
import type { ElementType } from "react"; import type { ElementType } from "react";
@ -26,6 +36,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
Overdue: Clock, Overdue: Clock,
}; };
function nextSeverityForType(
currentType: WOType | "",
nextType: WOType,
currentSeverity: WorkOrderSeverity | null,
): WorkOrderSeverity | null {
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
return null;
}
return currentSeverity;
}
function WoTypePicker({ function WoTypePicker({
selected, selected,
onSelect, onSelect,
@ -113,40 +134,151 @@ function WoTypePicker({
); );
} }
type WizardTypeScheduleDatesProps = {
draft: WorkOrderWizardDraft;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeScheduleDatesProps) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</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;
onDuplicateWoNumber?: (woNumber: string) => void;
}; };
export function WizardStepTypeSchedule({ export function WizardStepTypeSchedule({
draft, draft,
users, users,
showErrors = false,
onUpdate, onUpdate,
onPatch, onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) { }: WizardStepTypeScheduleProps) {
const needsSeverity = typeNeedsSeverity(draft.type);
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId); const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
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) => {
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
});
}}
/>
</div> </div>
{Boolean(needsSeverity) && (
<WizardSeverityPicker
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"
inputMode="numeric"
value={draft.woNumber} value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))} onChange={(e) =>
placeholder="e.g. 12345678901" onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
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>
@ -177,77 +309,7 @@ export function WizardStepTypeSchedule({
/> />
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> <WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd
? () =>
onPatch({
scheduledOn: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
targetWeek: "",
})
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
})
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
})
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDueDateField
type={draft.type}
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
</div> </div>
); );
} }

View file

@ -1,4 +1,4 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { import {
@ -74,7 +74,7 @@ function WizardVendorDetail({
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}> <div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{vendor.companyName || "ÔÇö"} {vendor.companyName || "—"}
</div> </div>
{Boolean(distance) && ( {Boolean(distance) && (
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}> <span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
@ -155,7 +155,7 @@ export function WizardStepVendorTime({
</WizLabelRow> </WizLabelRow>
<WizardFieldSelect <WizardFieldSelect
value={draft.vendorId} value={draft.vendorId}
placeholder="Select technicianÔǪ" placeholder="Select technician…"
icon={ icon={
selectedVendor ? ( selectedVendor ? (
<span <span
@ -178,7 +178,7 @@ export function WizardStepVendorTime({
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} /> <Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
) )
} }
searchPlaceholder="Search technicianÔǪ" searchPlaceholder="Search technician…"
options={vendors.map((v) => ({ options={vendors.map((v) => ({
key: String(v.id), key: String(v.id),
label: formatVendorTechnicianLabel(v), label: formatVendorTechnicianLabel(v),
@ -258,7 +258,7 @@ export function WizardStepVendorTime({
<WizLabel>Status</WizLabel> <WizLabel>Status</WizLabel>
<WizardFieldSelect <WizardFieldSelect
value={statusValue} value={statusValue}
placeholder="Select statusÔǪ" placeholder="Select status…"
search={false} search={false}
icon={<ColorDot color={statusDotColor} />} icon={<ColorDot color={statusDotColor} />}
options={buildWizardStatusOptions(closabilityGaps)} options={buildWizardStatusOptions(closabilityGaps)}

View file

@ -0,0 +1,82 @@
import { useMemo, useState } from "react";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useVendorEditor } from "@/app/(protected)/workorders/_hooks/use-vendor-editor";
export type WorkOrderActivityTab = "customer" | "internal" | "audit";
export function useWorkOrderViewController(workOrderId: string) {
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorEditor = useVendorEditor(workOrderId, () => void refetch());
const [activeTab, setActiveTab] = useState<WorkOrderActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
);
const internalComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
if (activeTab !== "customer" && activeTab !== "internal") {
return;
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
return {
wo,
isLoading,
error,
refetch,
users,
statusMutation,
assignmentMutation,
commentMutation,
vendorEditor,
activeTab,
setActiveTab,
commentText,
setCommentText,
dispatchOpen,
setDispatchOpen,
dispatchDetailId,
setDispatchDetailId,
statusAnchor,
setStatusAnchor,
assignAnchor,
setAssignAnchor,
customerComments,
internalComments,
auditLog,
handleCommentSubmit,
};
}
export type WorkOrderViewController = ReturnType<typeof useWorkOrderViewController>;

View file

@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
const startDayDraft = useCallback( const startDayDraft = useCallback(
(key: string, scheduledIso?: string) => { (key: string, scheduledIso?: string) => {
setDraftDayKey(key); setDraftDayKey(key);
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
if (key === UNSCHEDULED_DAY_KEY) {
setDraft({ ...EMPTY_TABLE_DRAFT });
return;
}
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso); const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
setDraft({ setDraft({
...EMPTY_TABLE_DRAFT, ...EMPTY_TABLE_DRAFT,
scheduledOn, scheduledOn,
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY, status: scheduledOn ? "Scheduled" : "Incomplete",
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
}); });
}, },
[weekMonday], [weekMonday],

View file

@ -1,28 +1,34 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header"; import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays"; import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
function openCompletionDoc(
row: WorkOrderTableRow,
setDocRow: (row: WorkOrderTableRow | null) => void,
) {
if (!hasCompletionDocService(row.pm)) return;
setDocRow(row);
}
export default function WorkOrdersListPage() { export default function WorkOrdersListPage() {
const { user } = useAuthContext(); const { user } = useAuthContext();
const page = useWorkOrdersListPage(); const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page; const { filters, tableData, activeDocRow } = page;
const openCompletionDoc = (row: WorkOrderTableRow) => {
if (!hasCompletionDocService(row.pm)) return;
page.setDocRow(row);
};
return ( return (
<Box className="flex h-full flex-col overflow-hidden p-2.5"> <Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader <PageHeader
@ -88,42 +94,31 @@ export default function WorkOrdersListPage() {
weekMonday={filters.weekMonday} weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday} onWeekChange={filters.setWeekMonday}
onOpenSlideOver={page.handleOpenSlideOver} onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={openCompletionDoc} onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow} onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")} onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)} onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField} onPatch={page.patchField}
/> />
<WorkOrdersListOverlays <WorkOrdersListWizard
wizardOpen={page.wizardOpen} open={page.wizardOpen}
onWizardOpenChange={page.setWizardOpen} onOpenChange={page.setWizardOpen}
users={page.users} users={page.users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={page.locations} sites={page.locations}
users={page.users}
pmTypes={page.pmTypes} pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs} vendorTechs={page.vendorTechs}
advOpen={filters.advOpen}
onAdvOpenChange={filters.setAdvOpen}
advInitial={filters.advInitial}
onApplyAdvanced={filters.applyAdvancedFilters}
onClearAdvanced={filters.clearAdvancedFilters}
activeDocRow={activeDocRow}
onDocOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
completionDocs={page.completionDocs}
onSaveCompDoc={async (data, status) => {
if (!activeDocRow) return;
await page.saveCompletionDoc(activeDocRow, data, status);
}}
onDateCompletedChange={async (date) => {
if (!activeDocRow) return;
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
activeMediaRow={page.activeMediaRow}
onMediaOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/> />
<WorkOrderSlideOver <WorkOrderSlideOver
@ -134,8 +129,8 @@ export default function WorkOrdersListPage() {
vendors={page.vendors} vendors={page.vendors}
pmTypes={page.pmTypes} pmTypes={page.pmTypes}
allOrders={tableData.rows} allOrders={tableData.rows}
onSave={page.patchField} onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={openCompletionDoc} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onCancelWO={page.setConfirmCancel} onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)} onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending} saving={page.isPatchPending}
@ -149,6 +144,31 @@ export default function WorkOrdersListPage() {
onKeepWO={() => page.setConfirmCancel(null)} onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel} onConfirm={page.handleConfirmCancel}
/> />
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
</Box> </Box>
); );
} }

View file

@ -50,10 +50,17 @@ 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 variant="description" tone="muted" when={Boolean(subtitle)}> <Text
variant="description"
as="span"
tone="muted"
when={Boolean(subtitle)}
sx={{ mt: 0.75, fontWeight: 500 }}
>
{subtitle} {subtitle}
</Text> </Text>
</Stack> </Stack>

View file

@ -91,11 +91,6 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
? getSingleStatusOnlyChange(originalRoster, values) ? getSingleStatusOnlyChange(originalRoster, values)
: null; : null;
if (statusChange) { if (statusChange) {
if (statusChange.isActive === false) {
throw new Error(
"Use Deactivate to check open work orders before inactivating a technician.",
);
}
const baseRoster = originalRoster as VendorCompanyRoster; const baseRoster = originalRoster as VendorCompanyRoster;
await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive }); await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive });
return { return {

View file

@ -191,6 +191,48 @@ export const workOrdersApi = {
}; };
}, },
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
const normalized = woNumber.trim();
// Search every non-empty WO# — backend advanced search accepts 1-character queries, and
// wizard/create allow single-digit numbers (SH-189 must not bypass remote uniqueness).
if (!normalized) {
return undefined;
}
// Safety cap — if hit before exhausting results, fail closed (do not treat as unique).
const safetyMaxPages = 50;
// `board/search` is fuzzy and paginated (0-based); exhaust pages for an exact WO#.
const pageSize = 100;
let page = 0;
let scanned = 0;
let totalCount = Number.POSITIVE_INFINITY;
while (scanned < totalCount) {
if (page >= safetyMaxPages) {
throw new Error(
"Unable to verify the work order number: search result set exceeded the safety page limit.",
);
}
const result = await workOrdersApi.searchBoard({
search: normalized,
page,
pageSize,
});
const exact = result.items.find((row) => row.woNumber === normalized);
if (exact) {
return exact;
}
scanned += result.items.length;
totalCount = result.totalCount;
if (result.items.length === 0) {
break;
}
page += 1;
}
return undefined;
},
getListByLocation: async ( getListByLocation: async (
locationId: string | number, locationId: string | number,
search = "", search = "",

View file

@ -18,11 +18,11 @@ export function assignWizardScheduleFields(
const isRange = draft.schedMode === "range"; const isRange = draft.schedMode === "range";
if (!isRange && draft.scheduleWeekOnly) { if (!isRange && draft.scheduleWeekOnly) {
payload.scheduleWeekOnly = true;
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : ""); const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
// Only emit week-mode flags when a concrete week exists (SH-109: undated create).
if (week) { if (week) {
payload.scheduleWeekOnly = true;
payload.targetWeek = week; payload.targetWeek = week;
} }
} else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) { } else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {

View file

@ -53,4 +53,8 @@ export function assignWizardServicePocFields(
if (vendorNotes) { if (vendorNotes) {
payload.vendorNotes = vendorNotes; payload.vendorNotes = vendorNotes;
} }
if (draft.severity != null) {
payload.severity = draft.severity;
}
} }

View file

@ -18,6 +18,12 @@ type ScalarBoardOpRule = {
/** One row per scalar patch field, in emission order. */ /** One row per scalar patch field, in emission order. */
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string }, { key: "woNumber", field: "woNumber", toValue: (value) => value as string },
// Severity before type so Emergency/Reactive never land without required severity.
{
key: "severity",
field: "severity",
toValue: (value) => (value == null ? "" : String(value)),
},
{ key: "type", field: "workOrderType", toValue: (value) => value as string }, { key: "type", field: "workOrderType", toValue: (value) => value as string },
{ key: "site", field: "siteCode", toValue: (value) => value as string }, { key: "site", field: "siteCode", toValue: (value) => value as string },
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string }, { key: "status", field: "lifecycleStatus", toValue: (value) => value as string },

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,
@ -118,6 +120,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
trade: "", trade: "",
subTrade: "", subTrade: "",
vendorNTE: "", vendorNTE: "",
source: draft.type, source: effectiveWizardType(draft.type),
}; };
} }

View file

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

@ -93,6 +93,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). */
@ -122,7 +124,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 {
@ -147,8 +150,12 @@ 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;
/** ISO date when work was completed; optional on list rows until BE confirms. */
completedDate?: string | null;
targetWeek?: string | null; targetWeek?: string | null;
scheduleWeekOnly?: boolean | null; scheduleWeekOnly?: boolean | null;
dayGroup?: string | null; dayGroup?: 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;
@ -57,6 +60,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;
@ -84,6 +88,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

@ -0,0 +1,16 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
export function findDuplicateWoNumberRow(
woNumber: string,
rows: WorkOrderTableRow[],
excludeId?: string | number,
): WorkOrderTableRow | undefined {
const normalized = woNumber.trim();
if (normalized === "") {
return undefined;
}
return rows.find(
(row) => row.woNumber === normalized && String(row.id) !== String(excludeId ?? ""),
);
}

View file

@ -0,0 +1,62 @@
/** Required fields for board inline create (Schedule On is optional — SH-109). */
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
site: "Site",
woNumber: "WO #",
pocName: "POC name",
pocPhone: "POC phone",
};
export type InlineCreateDraftFields = {
site?: string;
locationId?: string | number;
woNumber?: string;
pocName?: string;
pocPhone?: string;
};
export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
const miss: InlineCreateReqField[] = [];
const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
if (!hasSite) {
miss.push("site");
}
if (!d.woNumber?.trim()) {
miss.push("woNumber");
}
if (!d.pocName?.trim()) {
miss.push("pocName");
}
if (!d.pocPhone?.trim()) {
miss.push("pocPhone");
}
return miss;
}
/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
if (miss.length === 0) {
return "Save";
}
return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
}
export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
if (miss.includes("site")) {
return "Site is required";
}
return "Contact name and phone are required";
}
/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
export function draftHasScheduleDate(d: {
scheduledOn?: string;
scheduleWeekOnly?: boolean;
schedMode?: string;
}): boolean {
if (d.scheduleWeekOnly || d.schedMode === "week") {
return false;
}
return Boolean(d.scheduledOn?.trim());
}

View file

@ -18,6 +18,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"targetWeek", "targetWeek",
"scheduleWeekOnly", "scheduleWeekOnly",
"dueDate", "dueDate",
"completedDate",
"apptTime", "apptTime",
"pm", "pm",
"serviceNotes", "serviceNotes",

View file

@ -5,6 +5,7 @@ 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) },
@ -24,8 +25,6 @@ vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] } }), useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
})); }));
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => { 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")
@ -202,10 +201,47 @@ 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();
saveMutate.mockReset();
}); });
it("keeps the newest company roster when two lookups resolve out of order", async () => { it("keeps the newest company roster when two lookups resolve out of order", async () => {
@ -280,97 +316,3 @@ describe("useVendorRosterForm stale-selection handling", () => {
expect(result.current.name.field.value).toBe("Draft Vendor"); expect(result.current.name.field.value).toBe("Draft Vendor");
}); });
}); });
describe("useVendorRosterForm blank technician filtering", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("omits blank new technician rows from the save payload", () => {
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
contactName: "Taylor",
phone: "(314) 555-0199",
email: "taylor@gateway.test",
tradeSpecialties: "Plumbing",
isActive: true,
},
{
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
],
});
});
expect(saveMutate).toHaveBeenCalledTimes(1);
const payload = saveMutate.mock.calls[0]?.[0] as {
values: { technicians: Array<{ contactName: string }> };
};
expect(payload.values.technicians).toHaveLength(1);
expect(payload.values.technicians[0]?.contactName).toBe("Taylor");
});
it("keeps existing technicians with an id even when contact fields are blank", () => {
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
id: 9,
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
],
});
});
const payload = saveMutate.mock.calls[0]?.[0] as {
values: { technicians: Array<{ id?: number }> };
};
expect(payload.values.technicians).toEqual([
{
id: 9,
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
]);
});
});

View file

@ -49,11 +49,11 @@ function rosterWith(technicians: Array<Record<string, unknown>>) {
}; };
} }
describe("VendorDetailDrawer preferred-contact display", () => { describe("VendorDetailDrawer selected-technician 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: 7, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 }, { id: 1, contactName: "Adam", phone: "314-555-0198", isActive: true, totalJobs: 5 },
]), ]),
); );
@ -62,15 +62,15 @@ describe("VendorDetailDrawer preferred-contact display", () => {
withAuth: false, withAuth: false,
}); });
expect(screen.getByText("Adam")).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Adam" })).toBeInTheDocument();
expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument(); expect(screen.queryByText(/^Prefers/)).not.toBeInTheDocument();
}); });
it("renders the preference label when an explicit preferredContact is present", () => { it("keeps the retired preferred-contact value out of the detail view", () => {
useVendorCompanyRoster.mockReturnValue( useVendorCompanyRoster.mockReturnValue(
rosterWith([ rosterWith([
{ {
id: 7, id: 1,
contactName: "Adam", contactName: "Adam",
phone: "314-555-0198", phone: "314-555-0198",
preferredContact: "Email", preferredContact: "Email",
@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => {
withAuth: false, withAuth: false,
}); });
expect(screen.getByText("Prefers Email")).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Adam" })).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,21 +1,14 @@
import { screen } from "@testing-library/react"; import { screen, within } 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 { VendorListItem } from "@/domain/vendors/types/vendor"; import type { VendorCompanyRoster, 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),
})); }));
@ -61,6 +54,32 @@ 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 },
@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) {
} }
function setupDefaults() { function setupDefaults() {
useVendorCompanyRoster.mockReturnValue({ data: undefined, isLoading: false, error: null }); useVendorCompanyRoster.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorDeactivationImpact.mockReturnValue({ useVendorDeactivationImpact.mockReturnValue({
data: undefined, data: undefined,
isLoading: false, isLoading: false,
@ -80,7 +105,7 @@ function setupDefaults() {
} }
describe("VendorsListPage", () => { describe("VendorsListPage", () => {
it("renders prototype-aligned status tabs and location, then hides deactivation for inactive rows", async () => { it("renders prototype-aligned status and two row actions in both tabs", 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);
@ -91,11 +116,20 @@ 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("button", { name: "Deactivate vendor" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Status" })).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();
}); });
@ -114,39 +148,18 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false }); renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument(); expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument(); expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
}); });
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => { it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
setupDefaults(); setupDefaults();
useVendorCompanyRoster.mockReturnValue({ useVendorCompanyRoster.mockReturnValue({
data: { data: activeRoster,
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);
@ -161,11 +174,18 @@ describe("VendorsListPage", () => {
row.focus(); row.focus();
await userEvent.keyboard("{Enter}"); await userEvent.keyboard("{Enter}");
expect(screen.getByRole("heading", { level: 2, name: "Gateway Plumbing" })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 2, name: "Adam Whyte" })).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,
@ -189,7 +209,8 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false }); renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
await userEvent.click(screen.getByRole("button", { name: "Deactivate vendor" })); await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" }));
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/),
@ -199,9 +220,51 @@ describe("VendorsListPage", () => {
expect(mutate).not.toHaveBeenCalled(); expect(mutate).not.toHaveBeenCalled();
}); });
it("navigates to the vendor edit route when the table Edit control is clicked", async () => { it("preserves inline edits when deactivation is cancelled", async () => {
setupDefaults(); setupDefaults();
navigate.mockClear(); useVendorCompanyRoster.mockReturnValue({
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");
}, 10_000);
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);
@ -211,16 +274,20 @@ 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(navigate).toHaveBeenCalledWith("/vendors/edit/1"); expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect( expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
).not.toBeInTheDocument();
}); });
it("navigates to the company route when editing a vendor without a vendor id", async () => { it("opens inline company edit when the row has no 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);
@ -230,6 +297,7 @@ 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(navigate).toHaveBeenCalledWith("/vendors/company/co-9"); expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
}); });
}); });

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
describe("buildInlineDraftCreateInput", () => {
it("defaults type to PM and omits schedule flags when undated", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
scheduleWeekOnly: true,
targetWeek: "",
scheduledOn: "",
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
expect(input.type).toBe("PM");
expect(input.scheduleWeekOnly).toBe(false);
expect(input.scheduledOn).toBe("");
expect(input.targetWeek).toBe("");
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(payload.scheduledDate).toBeUndefined();
expect(payload.targetWeek).toBeUndefined();
expect(payload.scheduleWeekOnly).toBeUndefined();
expect(payload.woNumber).toBe("25001");
expect(payload.pocName).toBe("Jane");
});
it("keeps week-only schedule when targetWeek is set", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
woNumber: "25002",
type: "Reactive" as const,
pocName: "Jane",
pocPhone: "555-0100",
scheduleWeekOnly: true,
targetWeek: "2026-06-22",
};
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(payload.scheduleWeekOnly).toBe(true);
expect(payload.targetWeek).toBe("2026-06-22");
expect(payload.scheduledDate).toBeUndefined();
});
});

View file

@ -28,11 +28,12 @@ vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"
const SCHEDULED_ON = "2026-06-01"; const SCHEDULED_ON = "2026-06-01";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow { function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return { const defaults: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: SCHEDULED_ON, scheduledOn: SCHEDULED_ON,
@ -71,7 +72,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "", rowVersion: "",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
};
return {
...defaults,
...overrides, ...overrides,
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
}; };
} }

View file

@ -0,0 +1,58 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
describe("DuplicateWoNumberDialog", () => {
it("shows the duplicate title and work order number when open", () => {
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={vi.fn()}
onViewExisting={vi.fn()}
/>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
expect(screen.getByText(/12345678901/)).toBeInTheDocument();
});
it("calls onViewExisting when View existing is clicked", () => {
const onViewExisting = vi.fn();
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={vi.fn()}
onViewExisting={onViewExisting}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "View existing" }));
expect(onViewExisting).toHaveBeenCalledWith(existingRow);
});
it("calls onDismiss when Dismiss is clicked", () => {
const onDismiss = vi.fn();
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={onDismiss}
onViewExisting={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View file

@ -45,6 +45,7 @@ function baseRow(): WorkOrderTableRow {
woNumber: "WO-7", woNumber: "WO-7",
site: "SEA1", site: "SEA1",
type: "Emergency", type: "Emergency",
severity: 1,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01", scheduledOn: "2026-06-01",

View file

@ -0,0 +1,91 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
const findByWoNumber = vi.fn();
vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () => ({
useCreateWorkOrderFromWizard: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderVendors: () => ({ data: [] }),
}));
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
useLocationSites: () => ({ data: [] }),
}));
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [] }),
}));
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
findByWoNumber: (...args: unknown[]) => findByWoNumber(...args),
},
}));
describe("NewWoWizard duplicate wo number", () => {
it("shows the duplicate dialog when the wizard wo number matches an existing row", async () => {
renderWithProviders(
<NewWoWizard
open
onOpenChange={vi.fn()}
users={[]}
existingRows={[existingRow]}
onViewExistingWo={vi.fn()}
/>,
);
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
fireEvent.change(woNumberInput, {
target: { value: "12345678901" },
});
fireEvent.blur(woNumberInput);
await waitFor(() => {
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
});
expect(screen.getByText(/is already assigned to another work order/i)).toBeInTheDocument();
});
it("finds a duplicate outside the loaded board rows", async () => {
const offPageRow = {
id: "wo-off-page",
woNumber: "98765432109",
} as WorkOrderTableRow;
findByWoNumber.mockResolvedValueOnce(offPageRow);
renderWithProviders(
<NewWoWizard
open
onOpenChange={vi.fn()}
users={[]}
existingRows={[]}
onViewExistingWo={vi.fn()}
/>,
);
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number");
fireEvent.change(woNumberInput, {
target: { value: "98765432109" },
});
fireEvent.blur(woNumberInput);
await waitFor(() => {
expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument();
});
expect(findByWoNumber).toHaveBeenCalledWith("98765432109");
});
});

View file

@ -15,11 +15,12 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
})); }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow { function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return { const defaults: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01", scheduledOn: "2026-06-01",
@ -58,7 +59,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "", rowVersion: "",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
};
return {
...defaults,
...overrides, ...overrides,
severity: overrides.severity !== undefined ? overrides.severity : defaults.severity,
}; };
} }

View file

@ -0,0 +1,112 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
import type { Location } from "@/domain/locations/types/location";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
const detailById = new Map<string, Location>();
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: (id: string | undefined) => ({
data: id && detailById.has(id) ? detailById.get(id) : undefined,
isLoading: Boolean(id && !detailById.has(id)),
isError: false,
}),
}));
const SITES: LocationOption[] = [
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
];
function locationDetail(id: string, contact: string, phone: string): Location {
return {
id,
name: id,
contact,
phone,
address: "100 Main St",
city: "Dallas",
state: "TX",
zipCode: "75001",
};
}
function renderSiteDialogState() {
return renderHook(() =>
useSiteDialogState({
open: true,
onOpenChange: vi.fn(),
value: "",
locationId: "",
pocName: "",
pocPhone: "",
pocNotes: "",
sites: SITES,
createMode: true,
onSave: vi.fn(),
}),
);
}
describe("useSiteDialogState POC autofill", () => {
beforeEach(() => {
detailById.clear();
});
it("preserves POC typed before deferred location detail resolves", () => {
const { result, rerender } = renderSiteDialogState();
act(() => {
result.current.handlePick("loc-1");
});
act(() => {
result.current.setPn("Dispatcher Typed");
result.current.setPp("999-000-1111");
});
expect(result.current.pn).toBe("Dispatcher Typed");
expect(result.current.pp).toBe("999-000-1111");
detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
act(() => {
rerender();
});
expect(result.current.pn).toBe("Dispatcher Typed");
expect(result.current.pp).toBe("999-000-1111");
});
it("clears prior POC on site switch then autofills from the new site detail", () => {
const { result, rerender } = renderSiteDialogState();
act(() => {
result.current.handlePick("loc-1");
});
detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
act(() => {
rerender();
});
expect(result.current.pn).toBe("Contact A");
expect(result.current.pp).toBe("111-111-1111");
act(() => {
result.current.handlePick("loc-2");
});
expect(result.current.locId).toBe("loc-2");
expect(result.current.pn).toBe("");
expect(result.current.pp).toBe("");
detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
act(() => {
rerender();
});
expect(result.current.pn).toBe("Contact B");
expect(result.current.pp).toBe("222-222-2222");
});
});

View file

@ -10,6 +10,7 @@ function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "100", woNumber: "100",
site: "SITE", site: "SITE",
type: "Emergency", type: "Emergency",
severity: null,
dispatcherId: "d1", dispatcherId: "d1",
dispatcherName: "Alex", dispatcherName: "Alex",
scheduledOn: "2026-07-16", scheduledOn: "2026-07-16",

View file

@ -0,0 +1,43 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow;
function buildTableData() {
return {
dragState: { current: { dragId: null, overId: null } },
setDragViz: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
setUnschedOpen: vi.fn(),
triggerFlash: vi.fn(),
setSelectedId: vi.fn(),
} as never;
}
describe("useWoTableRowHandlers onDuplicateFound", () => {
it("delegates to onDuplicateFound instead of opening the slide-over", () => {
const onDuplicateFound = vi.fn();
const onOpenSlideOver = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-07-20",
onWeekChange: vi.fn(),
onOpenSlideOver,
onOpenMedia: vi.fn(),
onPatch: vi.fn(),
onScheduleHighlight: vi.fn(),
onDuplicateFound,
}),
);
result.current.onDuplicateFound?.(existingRow);
expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
expect(onOpenSlideOver).not.toHaveBeenCalled();
});
});

View file

@ -14,9 +14,11 @@ const unscheduledRow: WorkOrderTableRow = {
woNumber: "WO-42", woNumber: "WO-42",
site: "CHI-1042", site: "CHI-1042",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Test Dispatcher", dispatcherName: "Test Dispatcher",
scheduledOn: "", scheduledOn: "",
completedDate: "",
targetWeek: "", targetWeek: "",
dayGroup: "", dayGroup: "",
dayLabel: "", dayLabel: "",
@ -38,7 +40,6 @@ const unscheduledRow: WorkOrderTableRow = {
pocPhone: "", pocPhone: "",
pocNotes: "", pocNotes: "",
dueDate: "2026-07-15", dueDate: "2026-07-15",
completedDate: "",
rescheduleCount: 0, rescheduleCount: 0,
carriedOver: 0, carriedOver: 0,
originalWeek: "", originalWeek: "",

View file

@ -53,6 +53,7 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "u1", dispatcherId: "u1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20", scheduledOn: "2026-07-20",

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",
@ -216,9 +217,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

@ -0,0 +1,52 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DispatchSnapshotDetails } from "@/app/v/[token]/dispatch/_components/dispatch-snapshot-details";
import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal";
import { renderWithProviders } from "@/test/test-utils";
function buildData(
overrides: Partial<VendorPortalDispatchDetail> = {},
): VendorPortalDispatchDetail {
return {
id: 9,
dispatchNumber: "DSP-9",
status: "Verified",
nteAmount: 500,
checklist: [],
comments: [],
upliftRequests: [],
signoffs: [],
documents: [],
...overrides,
};
}
describe("DispatchSnapshotDetails locked banner", () => {
it.each(["Verified", "Cancelled", "Refused"])(
"states that comments remain open when status is %s",
(status) => {
renderWithProviders(<DispatchSnapshotDetails data={buildData({ status })} locked />, {
withAuth: false,
});
expect(
screen.getByText(
new RegExp(
`This dispatch is ${status.toLowerCase()} and locked to further changes\\. Comments remain open\\.`,
"i",
),
),
).toBeInTheDocument();
expect(screen.queryByText(/sign-off/i)).not.toBeInTheDocument();
},
);
it("hides the locked banner when the dispatch is not locked", () => {
renderWithProviders(
<DispatchSnapshotDetails data={buildData({ status: "In Progress" })} locked={false} />,
{ withAuth: false },
);
expect(screen.queryByText(/locked to further changes/i)).not.toBeInTheDocument();
});
});

View file

@ -1,32 +1,7 @@
import { createElement, type ReactNode } from "react"; import { describe, expect, it } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getSingleStatusOnlyChange,
useSaveVendorCompanyRoster,
} from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor";
const updateVendor = vi.fn();
vi.mock("@/domain/vendors/api/vendors-api", () => ({
vendorsApi: {
update: (...args: unknown[]) => updateVendor(...args),
},
}));
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: {
update: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const roster: VendorCompanyRoster = { const roster: VendorCompanyRoster = {
companyId: 10, companyId: 10,
rowVersion: "rv-1", rowVersion: "rv-1",
@ -94,49 +69,3 @@ describe("getSingleStatusOnlyChange", () => {
).toBeNull(); ).toBeNull();
}); });
}); });
describe("useSaveVendorCompanyRoster status-only deactivation gate", () => {
beforeEach(() => {
updateVendor.mockReset();
updateVendor.mockResolvedValue(undefined);
});
it("rejects sparse Active→Inactive updates and still allows reactivation", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
const { result } = renderHook(() => useSaveVendorCompanyRoster(), { wrapper });
const activeRoster: VendorCompanyRoster = {
...roster,
technicians: [{ ...roster.technicians[0], isActive: true }],
};
await expect(
result.current.mutateAsync({
mode: "update",
values: {
...values,
technicians: [{ ...values.technicians[0], isActive: false }],
},
companyId: 10,
rowVersion: "rv-1",
originalRoster: activeRoster,
}),
).rejects.toThrow("Use Deactivate to check open work orders before inactivating a technician.");
expect(updateVendor).not.toHaveBeenCalled();
await result.current.mutateAsync({
mode: "update",
values: {
...values,
technicians: [{ ...values.technicians[0], isActive: true }],
},
companyId: 10,
rowVersion: "rv-1",
originalRoster: roster,
});
expect(updateVendor).toHaveBeenCalledWith(7, { isActive: true });
});
});

View file

@ -299,6 +299,157 @@ describe("workOrdersApi.searchBoard", () => {
}); });
}); });
describe("workOrdersApi.findByWoNumber", () => {
beforeEach(() => {
apiGet.mockReset();
});
it("searches globally without the active board filters", async () => {
apiGet.mockResolvedValue({
items: [
{
id: 505,
woNumber: "10000001235",
lifecycleStatusLabel: "Incomplete",
},
],
totalCount: 1,
page: 0,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000001235");
expect(apiGet).toHaveBeenCalledWith(
API_PATHS.workOrder.boardSearch,
expect.objectContaining({
searchParams: expect.any(URLSearchParams),
}),
);
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "10000001235",
page: "0",
pageSize: "100",
});
expect(result).toMatchObject({ id: 505, woNumber: "10000001235" });
});
it("pages through fuzzy search until the exact WO number is found", async () => {
apiGet
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 101,
page: 0,
pageSize: 100,
})
.mockResolvedValueOnce({
items: [
{
id: 999,
woNumber: "10000009999",
lifecycleStatusLabel: "Incomplete",
},
],
totalCount: 101,
page: 1,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000009999");
expect(apiGet).toHaveBeenCalledTimes(2);
const secondParams = apiGet.mock.calls[1]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(secondParams)).toMatchObject({
search: "10000009999",
page: "1",
pageSize: "100",
});
expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
});
it("skips remote search for empty WO numbers", async () => {
const result = await workOrdersApi.findByWoNumber(" ");
expect(apiGet).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("finds a one-character WO number duplicate outside loaded board rows", async () => {
apiGet.mockResolvedValue({
items: [
{
id: 7,
woNumber: "9",
lifecycleStatusLabel: "Incomplete",
},
],
totalCount: 1,
page: 0,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("9");
expect(apiGet).toHaveBeenCalledTimes(1);
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
expect(Object.fromEntries(searchParams)).toEqual({
search: "9",
page: "0",
pageSize: "100",
});
expect(result).toMatchObject({ id: 7, woNumber: "9" });
});
it("throws when the safety page limit is hit before exhausting results", async () => {
apiGet.mockResolvedValue({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 10_000,
page: 0,
pageSize: 100,
});
await expect(workOrdersApi.findByWoNumber("10000005555")).rejects.toThrow(/safety page limit/i);
expect(apiGet).toHaveBeenCalledTimes(50);
});
it("returns undefined after exhausting all fuzzy search pages", async () => {
apiGet
.mockResolvedValueOnce({
items: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
woNumber: `fuzzy-match-${index}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 150,
page: 0,
pageSize: 100,
})
.mockResolvedValueOnce({
items: Array.from({ length: 50 }, (_, index) => ({
id: index + 101,
woNumber: `fuzzy-match-${index + 100}`,
lifecycleStatusLabel: "Incomplete",
})),
totalCount: 150,
page: 1,
pageSize: 100,
});
const result = await workOrdersApi.findByWoNumber("10000005555");
expect(apiGet).toHaveBeenCalledTimes(2);
expect(result).toBeUndefined();
});
});
describe("workOrdersApi.patchBoardField", () => { describe("workOrdersApi.patchBoardField", () => {
beforeEach(() => { beforeEach(() => {
apiPatchFn.mockReset(); apiPatchFn.mockReset();

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { WO_TABLE_COLS } from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
import { expandScalarBoardOps } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
describe("COMPLETED DATE board column wiring", () => {
it("includes COMPLETED DATE column after COMP DOC", () => {
const keys = WO_TABLE_COLS.map((col) => col.key);
expect(keys).toContain("completed");
expect(keys.indexOf("completed")).toBeGreaterThan(keys.indexOf("doc"));
});
it("expands completedDate patch to board field op", () => {
const ops = expandScalarBoardOps({ completedDate: "2026-07-30" });
expect(ops).toEqual([{ field: "completedDate", value: "2026-07-30" }]);
});
it("maps completedDate returned by a board GET row", () => {
const row = mapWorkOrderTableRow({
id: 42,
completedDate: "2026-07-30T00:00:00Z",
});
expect(row.completedDate).toBe("2026-07-30");
});
});

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

@ -206,6 +206,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.scheduledDate).toBeUndefined(); expect(payload.scheduledDate).toBeUndefined();
}); });
it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
scheduleWeekOnly: true,
scheduledOn: "",
targetWeek: "",
});
expect(payload.scheduleWeekOnly).toBeUndefined();
expect(payload.targetWeek).toBeUndefined();
expect(payload.scheduledDate).toBeUndefined();
});
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => { it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
const payload = mapWizardDraftToBoardCreatePayload({ const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
@ -250,14 +265,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

@ -147,11 +147,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

@ -17,6 +17,7 @@ const BASE_ROW = {
id: 99, id: 99,
rowVersion: "AAAAAAA=", rowVersion: "AAAAAAA=",
completedDate: "", completedDate: "",
severity: null,
} as WorkOrderTableRow; } as WorkOrderTableRow;
describe("runWorkOrderTablePatch completedDate", () => { describe("runWorkOrderTablePatch completedDate", () => {

View file

@ -50,11 +50,12 @@ const BASE_SERVER: ServerState = {
docStatus: "No", docStatus: "No",
}; };
const BASE_ROW = { const BASE_ROW: WorkOrderTableRow = {
id: 42, id: 42,
woNumber: "WO-42", woNumber: "WO-42",
site: "SEA1", site: "SEA1",
type: "PM", type: "PM",
severity: null,
dispatcherId: "d1", dispatcherId: "d1",
dispatcherName: "Dispatcher", dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01", scheduledOn: "2026-06-01",
@ -93,7 +94,7 @@ const BASE_ROW = {
rowVersion: "AAAAAAA=", rowVersion: "AAAAAAA=",
dispatchRowVersion: "", dispatchRowVersion: "",
flagColor: null, flagColor: null,
} as WorkOrderTableRow; };
describe("useWorkOrderTableMutations completion-doc persistence", () => { describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: ServerState; let serverState: ServerState;

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const rows = [
{ id: "wo-1", woNumber: "12345678901" },
{ id: "wo-2", woNumber: "99999999999" },
] as WorkOrderTableRow[];
describe("findDuplicateWoNumberRow", () => {
it("returns a matching row when the wo number already exists", () => {
expect(findDuplicateWoNumberRow("12345678901", rows)).toEqual(rows[0]);
});
it("ignores blank values and excluded ids", () => {
expect(findDuplicateWoNumberRow("", rows)).toBeUndefined();
expect(findDuplicateWoNumberRow("12345678901", rows, "wo-1")).toBeUndefined();
});
});

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
draftHasScheduleDate,
getInlineCreateMissing,
inlineCreateSaveHint,
inlineCreateSiteError,
} from "@/domain/work-orders/utils/get-inline-create-missing";
describe("getInlineCreateMissing", () => {
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
expect(
getInlineCreateMissing({
site: "BK5",
locationId: "12",
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
}),
).toEqual([]);
});
it("accepts locationId alone as site presence", () => {
expect(
getInlineCreateMissing({
site: "",
locationId: 42,
woNumber: "25001",
pocName: "Jane",
pocPhone: "555-0100",
}),
).toEqual([]);
});
it("lists missing POC fields without requiring schedule", () => {
expect(
getInlineCreateMissing({
site: "BK5",
woNumber: "25001",
pocName: "",
pocPhone: "",
}),
).toEqual(["pocName", "pocPhone"]);
});
it("lists site and woNumber when empty", () => {
expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
});
});
describe("inlineCreateSaveHint", () => {
it("returns Save when ready", () => {
expect(inlineCreateSaveHint([])).toBe("Save");
});
it("lists missing labels", () => {
expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
});
});
describe("inlineCreateSiteError", () => {
it("prefers site-required message when site is missing", () => {
expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
});
it("surfaces contact message when only POC is missing", () => {
expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
"Contact name and phone are required",
);
});
});
describe("draftHasScheduleDate", () => {
it("is true for a concrete day", () => {
expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
});
it("is false for week-only", () => {
expect(
draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
).toBe(false);
});
});

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

@ -34,6 +34,7 @@ function row(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

@ -23,6 +23,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",

Some files were not shown because too many files have changed in this diff Show more