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 ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
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(
"aria-selected",
"true",
@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => {
"Phone",
"Email",
"Total Jobs",
"Status",
]) {
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("+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 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(/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(
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 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 expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
await page.getByRole("button", { name: /Company/ }).click();
await page.getByPlaceholder("All companies").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(/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(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
),
).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 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 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);
});
@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "+ Add Vendor" }).click();
await expect(
page.getByText("Register a company and add zero, one, or multiple technicians."),
).toBeVisible();
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
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("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
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();
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await page.getByRole("button", { name: "Move Plumbing up" }).click();
await page.getByLabel("Notes").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
expect(state.updatedBody).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
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("72")).toBeVisible();
await expect(
@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => {
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByLabel("Notes").fill("Updated in browser E2E");
await page.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(/\/vendors$/);
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
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 });
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 expect.poll(() => state.deletedId).toBe("1");
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();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByRole("switch", { name: "Inactive" }).first().click();
await page.getByRole("button", { name: "Save" }).click();
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
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 expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page);
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 page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => {
await expect(addButton).toBeFocused();
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.getByLabel("Company Phone").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
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" });
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
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 });
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
.getByRole("dialog", { name: "Deactivate Vendor" })
.getByRole("button", {
@ -555,6 +574,7 @@ test.describe("Vendor directory prototype parity", () => {
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
await page.getByRole("button", { name: "Open vendor filters" }).click();
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
await expect(filterDrawer).toBeVisible();

View file

@ -2,11 +2,5 @@
"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.",
"maxFileLines": 500,
"godfileDebt": [
{
"path": "src/app/(protected)/vendors/index.tsx",
"maxLines": 612,
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
}
]
"godfileDebt": []
}

View file

@ -15,9 +15,7 @@ export interface VendorDeactivationState {
confirm: () => void;
}
export function useVendorDeactivation(options?: {
onSuccess?: () => void;
}): VendorDeactivationState {
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
const [target, setTarget] = useState<VendorListItem | null>(null);
const [error, setError] = useState<string | null>(null);
const deleteVendor = useDeleteVendor();
@ -43,7 +41,7 @@ export function useVendorDeactivation(options?: {
deleteVendor.mutate(target.id, {
onSuccess: () => {
setTarget(null);
options?.onSuccess?.();
onSuccess?.();
},
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
});

View file

@ -31,7 +31,7 @@ export interface VendorListState {
export function useVendorListState(): VendorListState {
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [pageSize, setPageSize] = useState(48);
const [isActive, setIsActive] = useState(true);
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 {
emptyVendorCompanyRosterForm,
emptyRosterTechnician,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
@ -43,6 +44,7 @@ export interface VendorRosterFormProps {
vendorId?: string | number;
companyId?: string | number;
onSuccess?: (roster: VendorCompanyRoster) => void;
startWithTechnician?: boolean;
}
export interface VendorRosterForm {
@ -73,6 +75,7 @@ export function useVendorRosterForm({
vendorId,
companyId,
onSuccess,
startWithTechnician = false,
}: VendorRosterFormProps): VendorRosterForm {
const query = useVendorCompanyRoster(
mode === "update" ? vendorId : undefined,
@ -87,9 +90,16 @@ export function useVendorRosterForm({
const trades = useMemo(() => facets?.trades ?? [], [facets]);
const resolver = useVendorRosterResolver(routeRoster);
const createDefaults = useMemo(
() => ({
...emptyVendorCompanyRosterForm,
technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [],
}),
[startWithTechnician],
);
const form = useForm<VendorCompanyRosterFormValues>({
resolver,
defaultValues: emptyVendorCompanyRosterForm,
defaultValues: createDefaults,
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
mode: "onChange",
});
@ -114,10 +124,10 @@ export function useVendorRosterForm({
}, [routeRoster, watched]);
const resetForm = useCallback(() => {
reset(emptyVendorCompanyRosterForm);
reset(createDefaults);
resetSelection();
clearConflict();
}, [clearConflict, reset, resetSelection]);
}, [clearConflict, createDefaults, reset, resetSelection]);
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
const isUpdate = mode === "update" || selection.selectedRoster != null;

View file

@ -8,8 +8,8 @@ import {
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
@ -21,7 +21,11 @@ interface 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;
useEffect(() => {
@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
open={open}
onClose={form.isSaving ? undefined : onClose}
fullWidth
maxWidth="md"
maxWidth="sm"
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">
Add Vendor Company
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Register a company and add zero, one, or multiple technicians.
</Typography>
<Text variant="title" as="span">
Add Vendor
</Text>
<Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
Register a vendor company and its technicians.
</Text>
</DialogTitle>
<DialogContent>
<DialogContent sx={{ maxHeight: "55vh" }}>
<Stack spacing={2} className="mt-1">
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
variant="contained"
type="submit"
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>
</DialogActions>
</Dialog>

View file

@ -1,5 +1,5 @@
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "react-router";
import { useEffect, useState, type ReactNode } from "react";
import { Controller, useWatch } from "react-hook-form";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
@ -13,15 +13,22 @@ import {
Drawer,
IconButton,
Link,
List,
ListItem,
Stack,
Typography,
Switch,
} 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 type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
const DRAWER_WIDTH = 440;
const DRAWER_WIDTH = 420;
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] {
interface VendorDetailDrawerProps {
open: boolean;
vendor: VendorListItem | null;
startInEdit?: boolean;
onRequestDeactivation?: (vendor: VendorListItem) => void;
onClose: () => void;
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<Typography
variant="subtitle2"
className="font-semibold uppercase"
sx={{ color: "text.secondary", mb: 1 }}
<Text
variant="label"
tone="muted"
className="font-semibold tracking-[0.06em] uppercase"
sx={{ mb: 1 }}
>
{children}
</Typography>
</Text>
);
}
function Field({ label, value }: { label: string; value: string }) {
const display = value && value.trim() !== "" ? value : "—";
function Field({ label, value }: { label: string; value?: string }) {
const display = !value || value.trim() === "" ? "—" : value;
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">{display}</Typography>
</Text>
<Text variant="description">{display}</Text>
</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({
roster,
vendor,
technician,
onClose,
}: {
roster?: VendorCompanyRoster;
vendor: VendorListItem | null;
technician?: VendorRosterTechnician;
onClose: () => void;
}) {
return (
@ -141,23 +87,18 @@ function DrawerHeader({
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 3,
px: 2.5,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
<Typography
id="vendor-detail-drawer-title"
variant="subtitle1"
component="h2"
className="font-semibold"
>
{roster?.name || vendor?.companyName || "Vendor company"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
</Typography>
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
</Text>
<Text variant="description" tone="muted">
{roster?.name || vendor?.companyName || "Vendor details"}
</Text>
</Box>
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
@ -167,18 +108,19 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const mapsUrl = roster.googleMapsUrl;
const address = [roster.address, roster.city, roster.state, roster.zip]
.filter(Boolean)
.join(", ");
return (
<Stack spacing={1.5}>
<Field label="Company" value={roster.name} />
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Email" value={roster.email} />
<Field
label="Address"
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
/>
{Boolean(mapsUrl) && (
<Field label="Company Email" value={roster.email} />
<Field label="Address" value={address} />
{Boolean(roster.googleMapsUrl) && (
<Link
href={mapsUrl}
href={roster.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
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 (
<Box>
<SectionTitle>Technicians</SectionTitle>
{roster.technicians.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No technicians assigned to this company.
</Typography>
) : (
<List disablePadding>
{roster.technicians.map((technician) => (
<TechnicianEntry
key={String(technician.id ?? technician.contactName)}
contactName={technician.contactName}
phone={technician.phone}
email={technician.email}
preferredContact={technician.preferredContact}
tradeSpecialties={technician.tradeSpecialties}
isActive={technician.isActive}
totalJobs={technician.totalJobs}
<Stack spacing={1.5}>
<Field label="Phone" value={technician.phone} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
Trade
</Text>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
{trades.length > 0 ? (
trades.map((trade, index) => (
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
))
) : (
<Text variant="description">—</Text>
)}
</Stack>
</Box>
<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"}
/>
))}
</List>
)}
</Box>
</Stack>
</Stack>
</Box>
);
}
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
function DrawerBody({
roster,
technician,
}: {
roster: VendorCompanyRoster;
technician?: VendorRosterTechnician;
}) {
return (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
<Box>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
</Box>
<Divider />
<TechniciansSection roster={roster} />
{technician ? (
<TechnicianSection technician={technician} />
) : (
<Text variant="description" tone="muted">
No technicians assigned to this company.
</Text>
)}
{Boolean(roster.notes) && (
<>
<Divider />
<Box>
<SectionTitle>Notes</SectionTitle>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
{roster.notes}
</Typography>
</Text>
</Box>
</>
)}
@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
<Stack
direction="row"
sx={{
px: 3,
py: 2,
px: 2.5,
py: 1.5,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center",
justifyContent: "flex-end",
@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
);
}
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
const navigate = useNavigate();
function DrawerEditor({
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 companyId = vendor?.companyId;
const hasVendorId = vendorId != null;
@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
!hasVendorId ? (companyId ?? undefined) : undefined,
);
const editHref = hasVendorId
? `/vendors/edit/${vendorId}`
: `/vendors/company/${companyId ?? ""}`;
const selectedTechnician = resolveTechnician(roster, vendor);
useEffect(() => {
if (open) setIsEditing(startInEdit);
}, [open, startInEdit, vendor]);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "Escape" && !isEditing) onClose();
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [onClose, open]);
}, [isEditing, onClose, open]);
return (
<Drawer
open={open}
onClose={onClose}
onClose={(_event, reason) => {
if (reason === "escapeKeyDown" && isEditing) return;
onClose();
}}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
}}
>
<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">
<CircularProgress size={28} />
</Box>
@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer
</Alert>
) : roster ? (
<>
<DrawerBody roster={roster} />
<DrawerActions onEdit={() => navigate(editHref)} />
<DrawerBody roster={roster} technician={selectedTechnician} />
<DrawerActions onEdit={() => setIsEditing(true)} />
</>
) : (
<Box className="flex flex-1 items-center justify-center">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
<Text variant="description" tone="muted">
No vendor selected.
</Typography>
</Text>
</Box>
)}
</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 { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
import { VendorFilterOptions } from "./vendor-filter-options";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
interface VendorFiltersDrawerProps {
open: boolean;
@ -30,11 +30,6 @@ export function VendorFiltersDrawer({
}
}, [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 =
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
@ -84,16 +79,7 @@ export function VendorFiltersDrawer({
</Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<VendorFiltersDrawerFields
draft={draft}
setDraft={setDraft}
options={{
companies: companyOptions,
trades: tradeOptions,
locations: locationOptions,
jobBuckets: jobBucketOptions,
}}
/>
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
</Box>
<Stack
@ -109,7 +95,7 @@ export function VendorFiltersDrawer({
Clear all
</Button>
<Button variant="contained" onClick={handleApply} fullWidth>
Apply
Apply filters
</Button>
</Stack>
</Stack>

View file

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

View file

@ -1,44 +1,17 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router";
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 { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorDeactivation } from "./use-vendor-deactivation";
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 {
vendorId?: 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({
title,
subtitle,
@ -101,9 +74,6 @@ function submitLabel(isSaving: boolean, isEdit: boolean): string {
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
const navigate = useNavigate();
const isEdit = vendorId !== undefined || companyId !== undefined;
const deactivation = useVendorDeactivation({
onSuccess: () => navigate("/vendors"),
});
const form = useVendorRosterForm({
mode: isEdit ? "update" : "create",
vendorId,
@ -111,11 +81,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
onSuccess: () => navigate("/vendors"),
});
const handleRequestDeactivate = (technician: RosterTechnicianValues) => {
if (!form.roster || technician.id == null) return;
deactivation.open(toDeactivationListItem(form.roster, technician));
};
if (isEdit && form.isLoading) {
return (
<Box className="flex justify-center p-10">
@ -159,7 +124,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
control={form.control}
errors={form.errors}
tradeOptions={form.trades}
onRequestDeactivate={isEdit ? handleRequestDeactivate : undefined}
{...companySelectionProps}
/>
@ -181,17 +145,6 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
</Stack>
</Stack>
</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>
);
}

View file

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

View file

@ -1,11 +1,10 @@
import { type KeyboardEvent, type MouseEvent } from "react";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Avatar,
Chip,
Box,
CircularProgress,
IconButton,
Link,
@ -18,8 +17,8 @@ import {
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
const TABLE_COLUMNS = [
@ -30,9 +29,6 @@ const TABLE_COLUMNS = [
"Phone",
"Email",
"Total Jobs",
"Status",
"View",
"Edit",
] as const;
function getInitials(name: string): string {
@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void {
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 {
row: VendorListItem;
onOpenDetail: (row: VendorListItem) => void;
onOpenDeactivation: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
}
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
const navigate = useNavigate();
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
const hasVendorId = row.id != null;
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
const handleKeyDown = (event: KeyboardEvent) => {
@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
onKeyDown={handleKeyDown}
>
<TableCell>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.companyName || "—"}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
fontSize: 12,
backgroundColor: "var(--accent, #1976d2)",
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-primary)",
fontSize: 10.5,
fontWeight: 600,
}}
>
{getInitials(row.contactName)}
</Avatar>
<Typography variant="body2">{row.contactName || "—"}</Typography>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.contactName || "—"}
</Text>
</Stack>
</TableCell>
<TableCell>{locationText}</TableCell>
<TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<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>
)}
</TableCell>
<TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{row.phone ? (
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
<Link
href={`tel:${row.phone}`}
underline="hover"
color="inherit"
onClick={stopPropagation}
>
{row.phone}
</Link>
) : (
@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
</TableCell>
<TableCell>
{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}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell>
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell>
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
<VendorStatus isActive={row.isActive} />
</TableCell>
<TableCell align="center">
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell align="center">
<TableCell align="right">
<Stack
direction="row"
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">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
navigate(
row.id != null
? `/vendors/edit/${row.id}`
: `/vendors/company/${row.companyId ?? ""}`,
);
onOpenEdit(row);
}}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</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>
</TableCell>
</TableRow>
@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo
interface VendorsTableProps {
rows: VendorListItem[];
isActive: boolean;
isFiltered: boolean;
isLoading: boolean;
showError: boolean;
onOpenDetail: (row: VendorListItem) => void;
onOpenDeactivation: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
}
export function VendorsTable({
rows,
isActive,
isFiltered,
isLoading,
showError,
onOpenDetail,
onOpenDeactivation,
onOpenEdit,
}: VendorsTableProps) {
const showEmptyRow = !isLoading && !showError && rows.length === 0;
const columnCount = TABLE_COLUMNS.length + 2;
return (
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableContainer
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>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
{column}
</TableCell>
))}
<TableCell>Status</TableCell>
<TableCell align="right" aria-label="Vendor actions" />
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
<TableCell colSpan={columnCount} align="center" className="py-10">
<Stack spacing={1} sx={{ alignItems: "center" }}>
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
<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>
</TableRow>
) : (
@ -252,7 +338,7 @@ export function VendorsTable({
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
row={row}
onOpenDetail={onOpenDetail}
onOpenDeactivation={onOpenDeactivation}
onOpenEdit={onOpenEdit}
/>
))
)}

View file

@ -23,16 +23,91 @@ interface AppliedChip {
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(
filters: VendorFilters,
jobBucketLabels: Map<string, string>,
): AppliedChip[] {
const chips: AppliedChip[] = [];
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
filters.companies.forEach((value) =>
chips.push({ category: "companies", value, label: `Company: ${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) =>
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
chips.push({
category: "jobBuckets",
value,
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
}),
);
return chips;
}
@ -42,7 +117,12 @@ export default function VendorsListPage() {
const [filtersOpen, setFiltersOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
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, isLoading, isFetching, error } = useVendorsList(filters.listParams);
@ -61,7 +141,7 @@ export default function VendorsListPage() {
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
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
globalCount={activeCount + inactiveCount}
activeCount={activeCount}
@ -74,18 +154,15 @@ export default function VendorsListPage() {
onAddVendor={() => setCreateOpen(true)}
/>
<Tabs
value={filters.isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") =>
filters.setIsActiveWithReset(value === "active")
}
aria-label="Vendor status"
>
<Tab value="active" label={`Active (${activeCount})`} />
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
</Tabs>
<VendorStatusTabs
isActive={filters.isActive}
activeCount={activeCount}
inactiveCount={inactiveCount}
onChange={filters.setIsActiveWithReset}
/>
<VendorAppliedFiltersBar
resultCount={totalCount}
chips={appliedChips.map((chip) => ({
key: `${chip.category}-${chip.value}`,
label: chip.label,
@ -102,10 +179,18 @@ export default function VendorsListPage() {
<VendorsTable
rows={rows}
isActive={filters.isActive}
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
isLoading={isLoading}
showError={Boolean(error)}
onOpenDetail={setDetailTarget}
onOpenDeactivation={deactivation.open}
onOpenDetail={(row) => {
setDetailStartsInEdit(false);
setDetailTarget(row);
}}
onOpenEdit={(row) => {
setDetailStartsInEdit(true);
setDetailTarget(row);
}}
/>
<VendorsListFooter
@ -133,7 +218,9 @@ export default function VendorsListPage() {
<VendorDetailDrawer
open={Boolean(detailTarget)}
vendor={detailTarget}
onClose={() => setDetailTarget(null)}
startInEdit={detailStartsInEdit}
onRequestDeactivation={deactivation.open}
onClose={closeDetail}
/>
<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 locationName = draft.location || draft.site || location?.name || "";
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
woNumber: draft.woNumber,
type: draft.type,
type: draft.type || "PM",
severity: draft.severity,
assigneeId: draft.dispatcherId,
assigneeName: assignee?.name ?? "",
scheduledOn: draft.scheduledOn,
@ -35,7 +39,7 @@ export function buildInlineDraftCreateInput(
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
scheduleWeekOnly: draft.scheduleWeekOnly,
scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
};
}

View file

@ -15,6 +15,8 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
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({
@ -26,6 +28,7 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
onClear,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
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>
</>
);

View file

@ -1,43 +1,30 @@
import { useEffect, useState } from "react";
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 { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
value: string;
rescheduleCount: number;
carriedOver?: number;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) {
return null;
}
if (count < 2) return null;
return (
<span
title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
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({
value,
rescheduleCount,
carriedOver = 0,
q,
currentId,
allOrders,
onSave,
onDuplicateFound,
}: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
if (!editing) {
setDraft(value);
}
if (!editing) setDraft(value);
}, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => {
if (draft === value) {
if (draft === value || isChecking) {
setEditing(false);
return;
}
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
return;
}
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
setEditing(false);
setIsChecking(true);
void (async () => {
try {
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
if (duplicate) {
if (onDuplicateFound) {
onDuplicateFound(duplicate);
} else {
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
}
setDraft(value);
return;
}
onSave(draft);
} catch {
toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsChecking(false);
setEditing(false);
}
})();
};
const cancel = () => {
setDraft(value);
setEditing(false);
};
const handleBlur = () => {
if (isChecking) return;
if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
commit();
return;
}
setEditing(false);
};
@ -137,75 +169,34 @@ export function EditableWONumberCell({
return (
<div className="flex items-center gap-1.5">
{editing ? (
<input
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(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();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
<WoNumberEditor
draft={draft}
invalid={invalid}
isChecking={isChecking}
onDraftChange={setDraft}
onCommit={commit}
onCancel={cancel}
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
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
fontSize: 13,
fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4,
padding: "2px 4px",
}}
>
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
)}
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</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;
sites: LocationOption[];
q?: string;
/** Inline create: editable POC + Site/POC required to confirm the modal. */
createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@ -31,6 +33,7 @@ export function SiteCell({
pocNotes,
sites,
q,
createMode = false,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
@ -70,7 +73,15 @@ export function SiteCell({
)}
</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 />
</button>
@ -83,6 +94,7 @@ export function SiteCell({
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
createMode={createMode}
onSave={onSave}
/>
</>

View file

@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
pocName: string;
pocPhone: 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 (
<div className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
<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>
<div className="space-y-2">
<input
value={pocName}
readOnly
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
/>
<input
value={pocPhone}
readOnly
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
/>
<div>
<input
value={pocName}
readOnly={!editable}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={{
...DLG_INPUT_STYLE,
opacity: editable ? 1 : 0.85,
borderColor: nameMissing ? "var(--destructive)" : undefined,
}}
onChange={(e) => onPocNameChange?.(e.target.value)}
/>
<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
value={pocNotes}
readOnly
readOnly={!editable}
placeholder="Notes…"
rows={2}
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>

View file

@ -1,30 +1,20 @@
import { useEffect, useState, type MouseEvent } from "react";
import type { MouseEvent } from "react";
import { MapPin } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
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 { 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 { 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 = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
export type { SitePatch };
type SiteDialogProps = {
open: boolean;
@ -35,6 +25,7 @@ type SiteDialogProps = {
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
createMode?: boolean;
onSave: (patch: SitePatch) => void;
};
@ -47,62 +38,26 @@ export function SiteDialog({
pocPhone = "",
pocNotes = "",
sites,
createMode = false,
onSave,
}: SiteDialogProps) {
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 {
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) : "";
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("");
}
};
const s = useSiteDialogState({
open,
onOpenChange,
value,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
createMode,
onSave,
});
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={s.close}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -125,7 +80,7 @@ export function SiteDialog({
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect
value={locId}
value={s.locId}
placeholder="Select site …"
icon={
<MapPin
@ -139,45 +94,45 @@ export function SiteDialog({
key: String(site.id),
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>
{Boolean(locId) && (
{Boolean(s.locId) && (
<SiteDialogDetailPanel
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
addressPreview={addressPreview}
fallbackLabel={selected?.name || code || "—"}
locationDetail={s.locationDetail}
locationDetailLoading={s.locationDetailLoading}
locationDetailError={s.locationDetailError}
addressPreview={s.addressPreview}
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 className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
Cancel
</Button>
<Button
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);
}}
>
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
Save
</Button>
</div>

View file

@ -1,25 +1,49 @@
import { useState } from "react";
import { Check } from "lucide-react";
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 { HoverChevron } from "./hover-chevron";
import { SeverityBadge } from "./severity-badge";
import { TypeSeverityModal } from "./type-severity-modal";
import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeCellProps = {
value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string;
onSave: (v: WOType) => void;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => 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 [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 (
<>
@ -27,7 +51,6 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
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>
)}
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge
severity={severity!}
onClick={(e) => {
e.stopPropagation();
setModalSeed(value);
setResetSeverity(false);
setModalOpen(true);
}}
/>
)}
<HoverChevron />
</button>
@ -53,19 +86,22 @@ export function TypeCell({ value, q, onSave }: TypeCellProps) {
<button
key={t}
type="button"
onClick={() => {
onSave(t);
setAnchorEl(null);
}}
onClick={() => pick(t)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<WoTableTypeLabel type={t} />
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
</button>
))}
</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;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function useWoTableRowHandlers({
@ -30,6 +31,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const {
dragState: dragStateRef,
@ -97,8 +99,7 @@ export function useWoTableRowHandlers({
});
},
onDuplicateFound: (existing: WorkOrderTableRow) => {
tableData.setSelectedId(existing.id);
onOpenSlideOver(existing, false);
onDuplicateFound?.(existing);
},
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
@ -138,6 +139,7 @@ export function useWoTableRowHandlers({
onOpenMedia,
onPatch,
onScheduleHighlight,
onDuplicateFound,
],
);
}

View file

@ -12,8 +12,9 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
export const WO_TABLE_MIN_WIDTH = 1504;
export const WO_TABLE_MIN_WIDTH = 1634;
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 = {
canSave: boolean;
saving: boolean;
saveHint: string;
onSave: () => void;
onCancel: () => void;
};
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
export function WoTableInlineRowActions({
canSave,
saving,
saveHint,
onSave,
onCancel,
}: WoTableInlineRowActionsProps) {
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
<button
type="button"
onClick={onSave}
disabled={!canSave}
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
disabled={saving}
title={saveHint}
aria-label={saving ? "Saving work order" : "Save work order"}
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) => {
if (canSave) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";

View file

@ -1,20 +1,23 @@
import type { RefObject } from "react";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} 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 { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/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 { SiteCell } from "./cells/site-cell";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
type WoTableInlineRowCellsProps = {
draft: WorkOrderTableDraft;
@ -25,9 +28,41 @@ type WoTableInlineRowCellsProps = {
vendors: VendorDropdownItem[];
pmTypes: 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;
};
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({
draft,
weekDefault,
@ -37,49 +72,34 @@ export function WoTableInlineRowCells({
vendors,
pmTypes,
allRows,
siteCellRef,
woCellRef,
siteShowError,
siteError,
woShowError,
onPatch,
onDuplicateFound,
}: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft);
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
return (
<>
<td className="group/cell" style={{ padding: "0 14px" }}>
<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}
onSave={(p) =>
onPatch({
site: p.site,
location: p.location,
locationId: p.locationId,
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,
})
}
/>
</td>
<WoTableInlineIdentityCells
draft={draft}
sites={sites}
sitePlace={sitePlace}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={siteError}
woShowError={woShowError}
onPatch={onPatch}
onDuplicateFound={onDuplicateFound}
/>
<td style={{ padding: "0 14px" }}>
<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>
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}>
<AssignedToCell
@ -98,11 +118,15 @@ export function WoTableInlineRowCells({
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
onSaveWeek={(week) =>
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
}
onClear={
hasSchedule
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
: undefined
}
/>
</td>
@ -170,9 +194,8 @@ export function WoTableInlineRowCells({
/>
</td>
<td style={{ padding: "0 14px" }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</td>
<InlineMutedDashCell />
<InlineMutedDashCell />
</>
);
}

View file

@ -1,3 +1,4 @@
import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react";
import type {
@ -5,10 +6,17 @@ import type {
VendorDropdownItem,
WorkOrderUser,
} 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 { 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 { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
@ -16,72 +24,88 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
type WoTableInlineRowProps = {
draft: WorkOrderTableDraft;
draftDayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
allRows: { id: string | number; woNumber: string }[];
saving?: boolean;
onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => 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({
draft,
draftDayKey,
users,
sites,
sitePlace,
vendors,
pmTypes,
allRows,
saving = false,
onChange,
onSave,
onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) {
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 =
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
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 (
<tr
aria-busy={saving || undefined}
style={{
height: WO_TABLE_ROW_HEIGHT,
borderBottom: "1px solid var(--color-border)",
borderLeft: "2px solid var(--color-primary)",
background: "var(--color-primary-tint)",
opacity: saving ? 0.7 : 1,
pointerEvents: saving ? "none" : undefined,
@ -102,14 +126,21 @@ export function WoTableInlineRow({
vendors={vendors}
pmTypes={pmTypes}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}
siteShowError={siteShowError}
siteError={inlineCreateSiteError(missing)}
woShowError={woShowError}
onPatch={patch}
onDuplicateFound={onDuplicateFound}
/>
<td style={{ padding: "0 4px" }}>
<WoTableInlineRowActions
canSave={canSave}
saving={saving}
onSave={onSave}
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
onSave={attemptSave}
onCancel={onCancel}
/>
</td>

View file

@ -83,7 +83,12 @@ export function WoTableRowCells({
style={{ padding: "0 14px", ...mutationCellStyle }}
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
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 { ApptTimeCell } from "./cells/appt-time-cell";
import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
import { VendorCell } from "./cells/vendor-cell";
@ -102,6 +103,17 @@ export function WoTableRowServiceCells({
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
</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,
WorkOrderTableRow,
} 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 type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
import { WoTableBody } from "./wo-table-body";
import { WoTableHead } from "./wo-table-head";
@ -55,10 +55,14 @@ export function WoTable({
onPatch,
}: WoTableProps) {
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => {
tableData.cancelDraft();
const { handleInlineSave, saving } = useWoTableInlineCreate({
tableData,
users,
sites,
onDuplicateFound: setDuplicateRow,
});
const handlers = useWoTableRowHandlers({
@ -70,6 +74,7 @@ export function WoTable({
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,
onDuplicateFound: setDuplicateRow,
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -107,12 +112,11 @@ export function WoTable({
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={createMutation.isPending}
saving={saving}
onChange={tableData.setDraft}
onSave={() =>
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/>
);
@ -151,21 +155,33 @@ export function WoTable({
}
return (
<WoTableShell>
<table
style={{
width: "100%",
minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
<>
<WoTableShell>
<table
style={{
width: "100%",
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 { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
import { WizardStepHeader } from "@/app/(protected)/workorders/_components/wizard/wizard-step-header";
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 { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
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";
import { useNewWoWizardController } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller";
type NewWoWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
existingRows?: WorkOrderTableRow[];
onViewExistingWo?: (row: WorkOrderTableRow) => void;
};
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
const {
step,
setStep,
draft,
setDraft,
setField,
patchDraft: patchDraftOnly,
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));
export function NewWoWizard({
open,
onOpenChange,
users,
existingRows = [],
onViewExistingWo,
}: NewWoWizardProps) {
const wizard = useNewWoWizardController({ open, onOpenChange, existingRows });
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex flex-col overflow-hidden p-0",
style: {
borderRadius: 12,
width: 768,
maxWidth: 768,
height: 680,
maxHeight: 680,
pointerEvents: isCreating ? "none" : undefined,
opacity: isCreating ? 0.85 : 1,
<>
<Dialog
open={open}
onClose={wizard.handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex flex-col overflow-hidden p-0",
style: {
borderRadius: 12,
width: 768,
maxWidth: 768,
height: 680,
maxHeight: 680,
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)}
onCreate={handleCreate}
isCreating={isCreating}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<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 [svcSearch, setSvcSearch] = useState("");
const [showErrors, setShowErrors] = useState(false);
useEffect(() => {
if (open) {
@ -20,6 +21,7 @@ export function useNewWoWizardDraft(open: boolean) {
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setSvcOpen(false);
setSvcSearch("");
setShowErrors(false);
}
}, [open]);
@ -45,5 +47,7 @@ export function useNewWoWizardDraft(open: boolean) {
setSvcOpen,
svcSearch,
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 {
WIZARD_STEPS,
canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
@ -15,9 +17,24 @@ type WizardFooterProps = {
onBack: () => void;
onContinue: () => void;
onCreate: () => void;
onAttemptContinue?: () => void;
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({
step,
draft,
@ -25,11 +42,12 @@ export function WizardFooter({
onBack,
onContinue,
onCreate,
onAttemptContinue,
isCreating,
}: WizardFooterProps) {
const stepValid = isWizardStepValid(step, draft);
const canCreate = canCreateWorkOrderFromWizard(draft);
const canContinue1 = canContinueWizardStep1(draft);
const hint = stepHint(step, draft);
return (
<div
@ -60,20 +78,25 @@ export function WizardFooter({
)}
<div className="flex flex-1 justify-center">
{step === 1 && !canContinue1 && (
{Boolean(hint) && (
<span
className="inline-flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Lightbulb size={13} /> Pick a type to continue
<Lightbulb size={13} /> {hint}
</span>
)}
</div>
{step < WIZARD_STEPS.length ? (
<Button
disabled={!stepValid}
onClick={onContinue}
onClick={() => {
if (!stepValid) {
onAttemptContinue?.();
return;
}
onContinue();
}}
endIcon={<ChevronRight size={14} />}
sx={{
background: "var(--color-primary)",
@ -81,17 +104,23 @@ export function WizardFooter({
height: 36,
fontSize: 13,
fontWeight: 500,
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}),
...(!stepValid ? { opacity: 0.7 } : {}),
}}
>
Continue
</Button>
) : (
<Button
disabled={!canCreate || isCreating}
disabled={isCreating}
loading={isCreating}
loadingText="Creating…"
onClick={onCreate}
onClick={() => {
if (!canCreate) {
onAttemptContinue?.();
return;
}
onCreate();
}}
startIcon={!isCreating ? <Check size={14} /> : undefined}
sx={{
background: "var(--color-primary)",
@ -99,7 +128,7 @@ export function WizardFooter({
height: 36,
fontSize: 13,
fontWeight: 500,
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}),
...(!canCreate ? { opacity: 0.7 } : {}),
}}
>
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_STYLE,
} 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 = {
draft: WorkOrderWizardDraft;
showErrors?: boolean;
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 (
<div>
<WizSubLabel>Point of contact</WizSubLabel>
<WizLabel required>Point of contact</WizLabel>
<Text
as="p"
variant="description"
@ -38,6 +45,11 @@ export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocat
style={DLG_INPUT_STYLE}
/>
</div>
{Boolean(showErrors && pocIncomplete) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
POC name and phone are required.
</Text>
)}
<textarea
value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)}

View file

@ -16,6 +16,7 @@ type WizardStepLocationServiceProps = {
locationDetailLoading?: boolean;
locationDetailError?: boolean;
services: ServiceOption[];
showErrors?: boolean;
svcOpen: boolean;
onSvcOpenChange: (open: boolean) => void;
svcSearch: string;
@ -31,6 +32,7 @@ export function WizardStepLocationService({
locationDetailLoading,
locationDetailError,
services,
showErrors = false,
svcOpen,
onSvcOpenChange,
svcSearch,
@ -57,7 +59,7 @@ export function WizardStepLocationService({
locationDetailLoading={locationDetailLoading}
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 { Text } from "@/components/ui/text";
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 { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
@ -15,6 +24,7 @@ import {
WizLabel,
WizLabelRow,
} 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 type { ElementType } from "react";
@ -26,6 +36,17 @@ const WO_TYPE_ICONS: Record<WOType, ElementType> = {
Overdue: Clock,
};
function nextSeverityForType(
currentType: WOType | "",
nextType: WOType,
currentSeverity: WorkOrderSeverity | null,
): WorkOrderSeverity | null {
if (!typeNeedsSeverity(nextType) || currentType !== nextType) {
return null;
}
return currentSeverity;
}
function WoTypePicker({
selected,
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 = {
draft: WorkOrderWizardDraft;
users: WorkOrderUser[];
showErrors?: boolean;
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void;
};
export function WizardStepTypeSchedule({
draft,
users,
showErrors = false,
onUpdate,
onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) {
const needsSeverity = typeNeedsSeverity(draft.type);
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
return (
<div className="space-y-6">
<div>
<WizLabel required>Type of WO</WizLabel>
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
<WizLabel optional>Type of WO</WizLabel>
<WoTypePicker
selected={draft.type}
onSelect={(t) => {
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
});
}}
/>
</div>
{Boolean(needsSeverity) && (
<WizardSeverityPicker
value={draft.severity}
showError={showErrors}
onSelect={(severity) => onUpdate("severity", severity)}
/>
)}
<div>
<WizLabel optional>WO Number</WizLabel>
<input
type="text"
inputMode="numeric"
value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))}
placeholder="e.g. 12345678901"
onChange={(e) =>
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}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
{Boolean(woNumberError) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
WO number must be alphanumeric.
</Text>
)}
</div>
<div>
@ -177,77 +309,7 @@ export function WizardStepTypeSchedule({
/>
</div>
<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>
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
</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 type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
@ -74,7 +74,7 @@ function WizardVendorDetail({
>
<div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{vendor.companyName || "ÔÇö"}
{vendor.companyName || "—"}
</div>
{Boolean(distance) && (
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
@ -155,7 +155,7 @@ export function WizardStepVendorTime({
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician"
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
@ -178,7 +178,7 @@ export function WizardStepVendorTime({
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician"
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: formatVendorTechnicianLabel(v),
@ -258,7 +258,7 @@ export function WizardStepVendorTime({
<WizLabel>Status</WizLabel>
<WizardFieldSelect
value={statusValue}
placeholder="Select status"
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
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(
(key: string, scheduledIso?: string) => {
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);
setDraft({
...EMPTY_TABLE_DRAFT,
scheduledOn,
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
status: scheduledOn ? "Scheduled" : "Incomplete",
});
},
[weekMonday],

View file

@ -1,28 +1,34 @@
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
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 { 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 { 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 { 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 { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
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() {
const { user } = useAuthContext();
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
const openCompletionDoc = (row: WorkOrderTableRow) => {
if (!hasCompletionDocService(row.pm)) return;
page.setDocRow(row);
};
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
@ -88,42 +94,31 @@ export default function WorkOrdersListPage() {
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={openCompletionDoc}
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField}
/>
<WorkOrdersListOverlays
wizardOpen={page.wizardOpen}
onWizardOpenChange={page.setWizardOpen}
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
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}
users={page.users}
pmTypes={page.pmTypes}
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
@ -134,8 +129,8 @@ export default function WorkOrdersListPage() {
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={page.patchField}
onOpenCompletionDoc={openCompletionDoc}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
@ -149,6 +144,31 @@ export default function WorkOrdersListPage() {
onKeepWO={() => page.setConfirmCancel(null)}
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>
);
}

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",
eyebrow && "mt-1",
)}
sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }}
>
{title}
</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}
</Text>
</Stack>

View file

@ -91,11 +91,6 @@ export function useSaveVendorCompanyRoster(): UseMutationResult<
? getSingleStatusOnlyChange(originalRoster, values)
: null;
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;
await vendorsApi.update(statusChange.id, { isActive: statusChange.isActive });
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 (
locationId: string | number,
search = "",

View file

@ -18,11 +18,11 @@ export function assignWizardScheduleFields(
const isRange = draft.schedMode === "range";
if (!isRange && draft.scheduleWeekOnly) {
payload.scheduleWeekOnly = true;
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) {
payload.scheduleWeekOnly = true;
payload.targetWeek = week;
}
} else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) {

View file

@ -53,4 +53,8 @@ export function assignWizardServicePocFields(
if (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. */
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ 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: "site", field: "siteCode", 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 {
WOType,
WizardWOStatus,
WorkOrderWizardDraft,
import {
effectiveWizardType,
type WOType,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
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 {
const typeLabel = effectiveWizardType(draft.type);
if (draft.woNumber) {
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
return `${typeLabel} — #${draft.woNumber}`;
}
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 {
@ -103,7 +105,7 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
workOrderNumber: draft.woNumber,
title: buildTitle(draft),
locationId: draft.locationId,
priority: mapTypeToPriority(draft.type),
priority: mapTypeToPriority(effectiveWizardType(draft.type)),
status: mapWizardStatusToApi(draft.status),
createdDate: todayIso(),
dueDate: draft.dueDate,
@ -118,6 +120,6 @@ export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrd
trade: "",
subTrade: "",
vendorNTE: "",
source: draft.type,
source: effectiveWizardType(draft.type),
};
}

View file

@ -87,7 +87,7 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
export function mapWizardDraftToBoardCreatePayload(
draft: WorkOrderWizardDraft,
): WorkOrderBoardCreatePayload {
const workOrderType = mapWizardTypeToApi(draft.type);
const workOrderType = mapWizardTypeToApi(draft.type || "PM");
const siteCode = draft.siteCode.trim() || resolveSiteCode({ name: draft.locationName });

View file

@ -8,6 +8,7 @@ import type {
CompletionDocStatus,
WorkOrderTableRow,
} 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 { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
@ -234,6 +235,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
woNumber,
site: site || location.slice(0, 8),
type: readWoType(item),
severity: parseWorkOrderSeverity(item.severity ?? item.Severity),
dispatcherId,
dispatcherName,
scheduledOn,

View file

@ -93,6 +93,8 @@ export interface WorkOrderBoardCreatePayload {
pocNotes?: string;
techPhone?: string;
vendorNotes?: string;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number;
}
/** Payload for PATCH /workorders/{id}/board (one field per request). */
@ -122,7 +124,8 @@ export type WorkOrderBoardEditableField =
| "pm"
| "serviceNotes"
| "extraServices"
| "flagColor";
| "flagColor"
| "severity";
/** Espelha WorkOrderBoardRowDto (JSON camelCase). */
export interface WorkOrderBoardRowApi {
@ -147,8 +150,12 @@ export interface WorkOrderBoardRowApi {
color?: string | null;
/** Board flag color (#RRGGBB) or null when cleared. */
flagColor?: string | null;
/** Severity 1–5 for Emergency/Reactive. */
severity?: number | null;
dueDate?: string | null;
scheduledDate?: string | null;
/** ISO date when work was completed; optional on list rows until BE confirms. */
completedDate?: string | null;
targetWeek?: string | null;
scheduleWeekOnly?: boolean | 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 { WorkOrderSeverity } from "./work-order-severity";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
@ -9,6 +10,8 @@ export interface WorkOrderTableRow {
woNumber: string;
site: string;
type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
@ -57,6 +60,7 @@ export interface WorkOrderTableDraft {
site: string;
woNumber: string;
type: WOType | "";
severity: WorkOrderSeverity | null;
dispatcherId: string;
scheduledOn: string;
schedNotes: string;
@ -84,6 +88,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "",
woNumber: "",
type: "",
severity: null,
dispatcherId: "",
scheduledOn: "",
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 type WOType = (typeof WO_TYPES)[number];
@ -24,6 +28,8 @@ export interface WorkOrderWizardDraft {
siteCode: string;
woNumber: string;
type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
severity: WorkOrderSeverity | null;
assigneeId: string;
assigneeName: string;
scheduledOn: string;
@ -58,6 +64,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
siteCode: "",
woNumber: "",
type: "",
severity: null,
assigneeId: "",
assigneeName: "",
scheduledOn: "",
@ -81,13 +88,22 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
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 {
const trimmed = woNumber.trim();
if (!trimmed) {
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 }[] = [
@ -159,11 +175,22 @@ export const TIME_SLOTS: string[] = (() => {
})();
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 {
return Boolean(draft.type && draft.siteCode) && isWizardWoNumberValid(draft.woNumber);
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
}
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
@ -171,7 +198,7 @@ export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): bo
return canContinueWizardStep1(draft);
}
if (step === 2) {
return Boolean(draft.locationId && draft.siteCode);
return canContinueWizardStep2(draft);
}
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",
"scheduleWeekOnly",
"dueDate",
"completedDate",
"apptTime",
"pm",
"serviceNotes",

View file

@ -5,6 +5,7 @@ import { useController } from "react-hook-form";
import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn();
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
@ -24,8 +25,6 @@ vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
}));
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
const actual = await vi.importActual<
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", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
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");
});
});
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", () => {
useVendorCompanyRoster.mockReturnValue(
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,
});
expect(screen.getByText("Adam")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Adam" })).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(
rosterWith([
{
id: 7,
id: 1,
contactName: "Adam",
phone: "314-555-0198",
preferredContact: "Email",
@ -85,6 +85,52 @@ describe("VendorDetailDrawer preferred-contact display", () => {
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 { describe, expect, it, vi } from "vitest";
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";
const useVendorsList = vi.fn();
const useVendorCompanyRoster = vi.fn();
const useVendorDeactivationImpact = 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", () => ({
useVendorsList: (...args: unknown[]) => useVendorsList(...args),
}));
@ -61,6 +54,32 @@ const inactiveVendor = {
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) {
return {
data: { items, totalCount, page: 1, pageSize: 12 },
@ -71,7 +90,13 @@ function result(items: VendorListItem[], totalCount: number) {
}
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({
data: undefined,
isLoading: false,
@ -80,7 +105,7 @@ function setupDefaults() {
}
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();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
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.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)" }));
expect(screen.getByText("Drain Cleaning Bros")).toBeInTheDocument();
expect(screen.getByRole("cell", { name: "Inactive" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
});
@ -114,39 +148,18 @@ describe("VendorsListPage", () => {
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.getByText(/25 active vendor entries/)).toBeInTheDocument();
expect(screen.queryByText(/3 active vendor entries/)).not.toBeInTheDocument();
expect(screen.getByText(/25 active technicians/)).toBeInTheDocument();
expect(screen.queryByText(/3 active technicians/)).not.toBeInTheDocument();
});
it("opens the detail drawer when a row is clicked or activated by keyboard", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: {
companyId: "co-1",
rowVersion: "rv-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
address: "",
city: "St. Louis",
state: "MO",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
id: 1,
contactName: "Adam Whyte",
phone: "(314) 555-0198",
email: "dispatch@gatewayplumbing.com",
preferredContact: "Email",
tradeSpecialties: "Backflow Preventers",
isActive: true,
},
],
},
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
@ -161,11 +174,18 @@ describe("VendorsListPage", () => {
row.focus();
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 () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useVendorDeactivationImpact.mockReturnValue({
data: {
vendorId: 1,
@ -189,7 +209,8 @@ describe("VendorsListPage", () => {
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(
screen.getByText(/cannot be deactivated because it still has open work orders/),
@ -199,9 +220,51 @@ describe("VendorsListPage", () => {
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();
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 }) => {
if (params.pageSize === 1) return result([], 1);
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" }));
expect(navigate).toHaveBeenCalledWith("/vendors/edit/1");
expect(
screen.queryByRole("heading", { level: 2, name: "Gateway Plumbing" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing");
});
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();
navigate.mockClear();
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 }) => {
if (params.pageSize === 1) return result([], 1);
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" }));
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";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: SCHEDULED_ON,
@ -71,7 +72,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
};
return {
...defaults,
...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",
site: "SEA1",
type: "Emergency",
severity: 1,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
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 {
return {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
@ -58,7 +59,11 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
};
return {
...defaults,
...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",
site: "SITE",
type: "Emergency",
severity: null,
dispatcherId: "d1",
dispatcherName: "Alex",
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",
site: "CHI-1042",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
@ -38,7 +40,6 @@ const unscheduledRow: WorkOrderTableRow = {
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-15",
completedDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",

View file

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

View file

@ -72,6 +72,7 @@ const canceledRow: WorkOrderTableRow = {
woNumber: "WO-77",
site: "CHI-1042",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Test Dispatcher",
scheduledOn: "2026-07-20",
@ -216,9 +217,15 @@ describe("WorkOrdersListPage board-core public interface", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
expect(
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 }));
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { describe, expect, it } from "vitest";
import { getSingleStatusOnlyChange } from "@/domain/vendors/use-cases/use-save-vendor-company-roster";
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 = {
companyId: 10,
rowVersion: "rv-1",
@ -94,49 +69,3 @@ describe("getSingleStatusOnlyChange", () => {
).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", () => {
beforeEach(() => {
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 {
EMPTY_WIZARD_DRAFT,
canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
} from "@/domain/work-orders/types/work-order-wizard";
describe("mapWizardDraftToFormValues", () => {
@ -63,12 +65,18 @@ describe("mapWizardDraftToFormValues", () => {
});
describe("wizard step validation", () => {
it("blocks step 1 continue without type", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
it("allows step 1 continue without a woNumber for backend SH generation", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
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 };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
@ -84,6 +92,51 @@ describe("wizard step validation", () => {
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", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
@ -95,44 +148,54 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows create when type and siteCode are set (woNumber optional)", () => {
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", () => {
it("allows create when type, woNumber, site, and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345678901234567890",
locationId: "99",
siteCode: "BK5",
pocName: "Jordan",
pocPhone: "555-0100",
};
expect(canCreateWorkOrderFromWizard(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 = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "",
};
expect(isWizardWoNumberValid(draft.woNumber)).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 = {
...EMPTY_WIZARD_DRAFT,
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();
});
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", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
@ -250,14 +265,12 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.workOrderType).toBe(3);
});
it("throws when type is missing", () => {
expect(() =>
mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
siteCode: "BK5",
}),
).toThrow("Work order type is required");
it("defaults empty type to PM", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
siteCode: "BK5",
});
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.PM);
});
it("throws when siteCode cannot be resolved", () => {

View file

@ -59,6 +59,12 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ flagColor: null })).toEqual([
{ 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", () => {

View file

@ -147,11 +147,13 @@ describe("mapWorkOrderTableRow (board DTO)", () => {
docStatus: 1,
dayGroup: "wednesday",
flagColor: "#EF4444",
severity: 2,
});
expect(row.woNumber).toBe("10000001235");
expect(row.site).toBe("BK5");
expect(row.type).toBe("Reactive");
expect(row.severity).toBe(2);
expect(row.status).toBe("Scheduled");
expect(row.scheduledOn).toBe("2026-07-15");
expect(row.dueDate).toBe("2026-07-18");

View file

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

View file

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

View file

@ -34,6 +34,7 @@ function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
woNumber: "1001",
site: "SITE-A",
type: "PM",
severity: null,
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",

View file

@ -23,6 +23,7 @@ function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
woNumber: "12345678901",
site: "SITE01",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",

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