Compare commits

..

No commits in common. "f0d58c4214dc2b9888fbd3f183a0b25ecb2d7946" and "61cd859c528a7bbcab3ff7e60fb69b6120c76a29" have entirely different histories.

41 changed files with 209 additions and 2970 deletions

View file

@ -1,265 +0,0 @@
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
// Technicians registered inline from work-order entry points
// become real vendor records only on save, through the additive vendor-company roster
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-inline-technician-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const VENDORS = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
},
];
const ROSTER = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
vendorId: 41,
vendorName: "Vinewood LLC",
techName: "Adam Whyte",
title: "Quarterly HVAC PM",
rowVersion: "rv-1",
};
type Captured = { method: string; pathname: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function bodyOf(request: Request): unknown {
try {
return request.postDataJSON();
} catch {
return undefined;
}
}
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
const added = (body.addTechnicians ?? []).map((technician, index) => ({
...technician,
id: 42 + index,
}));
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(page: Page, writes: Captured[]) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
const method = request.method();
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
if (pathname.includes("/workorders/board/search")) {
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
}
if (pathname === "/api/workorders/board" && method === "GET") {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [BOARD_ROW],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
}
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: VENDORS });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
}
if (pathname === "/api/locations/10") {
return fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
}
return fulfillJson(route, { data: [] });
});
}
function vendorWrites(writes: Captured[]) {
return writes.filter(
(write) =>
write.pathname.startsWith("/api/vendors") ||
write.pathname.startsWith("/api/vendor-company-roster"),
);
}
async function openWizardVendorStep(page: Page) {
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select company/i }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await page.getByRole("button", { name: "+ Add technician" }).click();
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
await page.getByLabel("New Technician 1 phone").fill("3145550199");
}
test.describe("Inline technician registration", () => {
test("wizard persists the technician under the company on create and assigns it", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
// Staging a technician is local only.
expect(vendorWrites(writes)).toEqual([]);
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
.toBeTruthy();
expect(vendorWrites(writes)).toEqual([
{
method: "PATCH",
pathname: "/api/vendor-company-roster/7",
body: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
},
},
]);
const create = writes.find((write) => write.pathname === "/api/workorders/board");
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
// Reopening starts clean: the staged technician was discarded, not persisted.
await page.getByRole("button", { name: "New WO" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
expect(writes).toEqual([]);
});
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
await page
.locator("#wo-row-1")
.getByRole("button", { name: /Adam Whyte/ })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
await dialog.getByRole("button", { name: /^add$/i }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
.toBeTruthy();
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(JSON.stringify(boardPatch?.body)).toContain("42");
});
});

View file

@ -137,12 +137,6 @@ test.describe("wizard vendor company notes (SH-321)", () => {
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select company/ }).click();
await page
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.click();
await page.getByRole("button", { name: /Select technician/ }).click();
await page
.getByRole("button", { name: /Adam Whyte/ })

View file

@ -31,7 +31,6 @@ export const API_PATHS = {
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -168,8 +167,6 @@ export const API_PATHS = {
},
services: {
list: "services",
byId: (id: string | number) => `services/${id}`,
deactivate: (id: string | number) => `services/${id}/deactivate`,
},
user: {
list: "User",

View file

@ -1,379 +0,0 @@
import {
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Drawer,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
import {
SERVICE_WORK_ORDER_TYPES,
type CompletionDocTemplateOption,
type Service,
type ServiceWorkOrderType,
} from "@/domain/services/types/service";
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
const ICONS = [
{ key: "wrench", label: "Wrench", Icon: Wrench },
{ key: "droplets", label: "Droplets", Icon: Droplets },
{ key: "zap", label: "Zap", Icon: Zap },
{ key: "hammer", label: "Hammer", Icon: Hammer },
{ key: "settings", label: "Settings", Icon: Settings2 },
] as const;
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
const Icon = icon.Icon;
return <Icon size={size} aria-hidden="true" />;
}
type DetailsProps = {
service: Service | null;
open: boolean;
canManage: boolean;
canDeactivate: boolean;
onClose: () => void;
onEdit: (service: Service) => void;
onDeactivate: (service: Service) => void;
};
export function ServiceDetailsDrawer({
service,
open,
canManage,
canDeactivate,
onClose,
onEdit,
onDeactivate,
}: DetailsProps) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
{service !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{service.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{service.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{service.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => onEdit(service)}>
Edit
</Button>
)}
{canDeactivate && service.isActive && (
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
);
}
type EditorProps = {
mode: "add" | "edit" | null;
form: ServiceForm;
formError: string;
trades: string[];
templates: CompletionDocTemplateOption[];
canChangeActiveState: boolean;
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
onToggleType: (type: ServiceWorkOrderType) => void;
};
export function ServiceEditorDrawer({
mode,
form,
formError,
trades,
templates,
canChangeActiveState,
isSaving,
onClose,
onSave,
onUpdate,
onToggleType,
}: EditorProps) {
return (
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{mode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <AlertMessage message={formError} />}
<ServiceEditorFields
mode={mode}
form={form}
trades={trades}
templates={templates}
canChangeActiveState={canChangeActiveState}
onUpdate={onUpdate}
onToggleType={onToggleType}
/>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
);
}
type EditorFieldsProps = Pick<
EditorProps,
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
>;
function ServiceEditorFields({
mode,
form,
trades,
templates,
canChangeActiveState,
onUpdate,
onToggleType,
}: EditorFieldsProps) {
return (
<>
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => onUpdate("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => onUpdate("trade", event.target.value)}
>
{trades.map((trade) => (
<MenuItem key={trade} value={trade}>
{trade}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => onUpdate("iconKey", event.target.value)}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => onToggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
{mode === "edit" && canChangeActiveState && (
<FormControlLabel
control={
<Switch
checked={form.isActive ?? true}
onChange={(event) => onUpdate("isActive", event.target.checked)}
/>
}
label="Active"
/>
)}
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
onUpdate("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={templates.find((template) => template.id === form.completionDocTemplateId) ?? null}
onChange={(_, template) => onUpdate("completionDocTemplateId", template?.id ?? null)}
getOptionLabel={(template) => template.name}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
renderInput={(params) => (
<TextField {...params} label="Linked Template" placeholder="Select a template" />
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</>
);
}
function AlertMessage({ message }: { message: string }) {
return (
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{message}
</Box>
);
}
type DeactivateProps = {
service: Service | null;
open: boolean;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function DeactivateServiceDialog({
service,
open,
isPending,
onClose,
onConfirm,
}: DeactivateProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{service?.name} will no longer be selectable for new work orders. Existing work orders
will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
Deactivate
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,279 +0,0 @@
import {
Alert,
Box,
Button,
CircularProgress,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { Plus, Search } from "lucide-react";
import { SettingsNav } from "@/components/common/settings-nav";
import {
useServicesRegistryController,
type ServiceStatus,
} from "@/domain/services/use-cases/use-services-registry-controller";
import type { Service } from "@/domain/services/types/service";
import {
DeactivateServiceDialog,
ServiceDetailsDrawer,
ServiceEditorDrawer,
ServiceIcon,
} from "@/app/(protected)/settings/_components/services-registry-components";
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>
);
}
export function ServicesRegistryView() {
const controller = useServicesRegistryController();
return (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{controller.activeCount} active
</Typography>
</Box>
{controller.canManage && (
<Button
variant="contained"
onClick={controller.openCreate}
startIcon={<Plus size={16} />}
>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search service or trade..."
className="flex-1"
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
/>
</Stack>
<Tabs
value={controller.status}
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
aria-label="Service status"
className="mb-4"
sx={{
minHeight: 36,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", controller.activeCount)}
aria-label={`Active (${controller.activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", controller.inactiveCount)}
aria-label={`Inactive (${controller.inactiveCount})`}
/>
</Tabs>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
{controller.isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : controller.filteredServices.length === 0 ? (
<EmptyServices
search={Boolean(controller.search)}
status={controller.status}
onAdd={controller.canManage ? controller.openCreate : undefined}
/>
) : (
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
)}
{!controller.isLoading && (
<Stack
direction="row"
className="mt-2"
sx={{
justifyContent: "space-between",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
pt: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{controller.filteredServices.length} {controller.status} service
{controller.filteredServices.length === 1 ? "" : "s"}
{controller.search.trim() ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{controller.activeCount} active · {controller.inactiveCount} inactive
</Typography>
</Stack>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
canManage={controller.canManage}
canDeactivate={controller.canDeactivate}
onClose={() => controller.setSelected(null)}
onEdit={controller.openEdit}
onDeactivate={controller.setDeactivateTarget}
/>
<ServiceEditorDrawer
mode={controller.editorMode}
form={controller.form}
formError={controller.formError}
trades={controller.trades}
templates={controller.templates}
canChangeActiveState={controller.canDeactivate}
isSaving={controller.isSaving}
onClose={controller.closeEditor}
onSave={controller.save}
onUpdate={controller.updateForm}
onToggleType={controller.toggleType}
/>
<DeactivateServiceDialog
service={controller.deactivateTarget}
open={controller.deactivateTarget !== null}
isPending={controller.isDeactivating}
onClose={() => controller.setDeactivateTarget(null)}
onConfirm={controller.confirmDeactivate}
/>
</Box>
);
}
function ServicesTable({
services,
onSelect,
}: {
services: Service[];
onSelect: (service: Service) => void;
}) {
return (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{services.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => onSelect(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell align="right">›</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
function EmptyServices({
search,
status,
onAdd,
}: {
search: boolean;
status: ServiceStatus;
onAdd?: () => void;
}) {
return (
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
<Search size={22} aria-hidden="true" />
</Box>
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : `No ${status} services configured yet`}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: `There are no ${status} services matching this view.`}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
}

View file

@ -1,5 +0,0 @@
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
export default function ServicesPage() {
return <ServicesRegistryView />;
}

View file

@ -97,13 +97,12 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
// Uploading a signed PDF finalizes through the same Generate flow, so the
// completedDate/address prerequisites still persist before docStatus Yes.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,
onUploaded: dialog.markSignedPdfUploaded,
onUploaded: dialog.handleGenerate,
});
// A signed PDF on file means the document exists even if docState has not caught up.

View file

@ -20,6 +20,7 @@ import {
isWorkOrderFullyLocked,
} from "@/domain/work-orders/utils/work-order-lock";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
@ -36,17 +37,22 @@ type UseWorkOrderSlideOverArgs = {
saving?: boolean;
};
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
type CompletionUploadMutate = (
input: { file: File; workOrderVersion: string },
options: { onSuccess: () => Promise<void> | void },
) => void;
function uploadSlideOverCompletionPdf({
file,
row,
infoSource,
onSave,
mutate,
}: {
file: File;
row: WorkOrderTableRow | null;
infoSource: WorkOrderTableRow | null | undefined;
onSave: UseWorkOrderSlideOverArgs["onSave"];
mutate: CompletionUploadMutate;
}) {
const version = infoSource?.rowVersion;
@ -54,11 +60,27 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again.");
return;
}
// The upload transitions the completion document to Yes and bumps the work-order row
// version in the same server transaction; the mutation's onSuccess refreshes the board.
// A client docStatus patch here would carry the pre-upload version and 409 after a
// successful upload, so it is intentionally omitted.
mutate({ file, workOrderVersion: version });
mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
}
function resolveSlideOverSource(
@ -145,6 +167,7 @@ export function useWorkOrderSlideOver({
file,
row,
infoSource,
onSave,
mutate: uploadCompletionMutation.mutate,
});
};

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import {
buildCompanyOptions,
buildTechnicianOptions,
@ -37,7 +37,7 @@ export function useVendorDialogForm({
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const addTechnicians = useAddTechniciansToCompany();
const createVendor = useCreateVendor();
const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
@ -119,7 +119,7 @@ export function useVendorDialogForm({
anchorCompanyName,
tech: selected?.name ?? t,
phone,
addTechnicians,
createVendor,
onSave,
onOpenChange,
setSaveError,
@ -141,7 +141,7 @@ export function useVendorDialogForm({
companyVendor,
saveError,
canSubmit,
isPending: addTechnicians.isPending,
isPending: createVendor.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);

View file

@ -1,8 +1,9 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
@ -13,45 +14,13 @@ type UseVendorDialogSaveArgs = {
anchorCompanyName: string;
tech: string;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
createVendor: ReturnType<typeof useCreateVendor>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
canSubmit: boolean;
};
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
async function persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
}: {
allVendors: VendorDropdownItem[];
pendingPayload: VendorFormValues;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
}): Promise<VendorDropdownItem> {
const anchor = allVendors.find(
(vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName,
);
if (!anchor) {
throw new Error("Select an existing vendor company before adding a technician.");
}
const [created] = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: [
{
contactName: pendingPayload.contactName,
phone: phone.trim() || (pendingPayload.phone ?? ""),
tradeSpecialties: pendingPayload.tradeSpecialties,
},
],
});
if (created == null) throw new Error("Failed to create technician");
return created;
}
export function useVendorDialogSave({
allVendors,
pendingCreates,
@ -61,7 +30,7 @@ export function useVendorDialogSave({
anchorCompanyName,
tech,
phone,
addTechnicians,
createVendor,
onSave,
onOpenChange,
setSaveError,
@ -84,12 +53,11 @@ export function useVendorDialogSave({
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
saveVendor = await persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);

View file

@ -14,7 +14,6 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
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";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
@ -64,7 +63,7 @@ export function useNewWoWizardController({
}
};
const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
// Close the dialog first: the work-order create's isPending has already dropped, so
// holding the dialog open through the notes save would re-enable Create and unlock
// pointer events, and a second click would create a second work order. The save runs
@ -73,11 +72,6 @@ export function useNewWoWizardController({
onOpenChange(false);
void saveCompanyNotes(createdDraft);
});
const createMutation = useWizardCreateWithTechnicians({
vendors,
patchDraft: draftState.patchDraft,
createMutation: createWorkOrder,
});
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,

View file

@ -1,93 +0,0 @@
import { toast } from "react-toastify";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type {
WizardNewTechnician,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
type UseWizardCreateWithTechniciansArgs = {
vendors: VendorDropdownItem[];
patchDraft: (patch: Partial<WorkOrderWizardDraft>) => void;
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] {
return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== "");
}
/** Draft changes applied once inline technicians exist as real vendor records. */
export function buildCreatedTechniciansPatch(
draft: WorkOrderWizardDraft,
company: string,
pending: WizardNewTechnician[],
created: VendorDropdownItem[],
): Partial<WorkOrderWizardDraft> {
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
patch.vendorId = String(only.id);
patch.vendorName = company;
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
}
return patch;
}
/**
* Persists technicians registered inline on the Vendor & time step under the
* selected company, then creates the work order. Nothing is written before Create, so
* cancelling the wizard leaves no stray vendor records.
*/
export function useWizardCreateWithTechnicians({
vendors,
patchDraft,
createMutation,
}: UseWizardCreateWithTechniciansArgs) {
const addTechnicians = useAddTechniciansToCompany();
const mutate = (draft: WorkOrderWizardDraft) => {
const pending = pendingTechnicians(draft);
const company = (draft.vendorCompany ?? "").trim();
if (pending.length === 0 || company === "") {
createMutation.mutate(draft);
return;
}
// Mirror the vendor roster schema so an unparseable phone is caught here with a
// clear message instead of failing the roster PATCH and blocking Create with the
// raw backend sentence. A blank phone stays valid, matching the modal path.
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
toast.error("Enter a 10-digit phone number for each new technician.");
return;
}
const anchor = vendors.find((vendor) => vendor.companyName === company);
if (!anchor) {
toast.error("Select an existing vendor company before adding technicians.");
return;
}
void (async () => {
try {
const created = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: pending.map((technician) => ({
contactName: technician.name,
phone: technician.phone,
tradeSpecialties: anchor.tradeSpecialties,
})),
});
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
patchDraft(patch);
createMutation.mutate({ ...draft, ...patch });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to add technicians");
}
})();
};
return { mutate, isPending: createMutation.isPending || addTechnicians.isPending };
}

View file

@ -29,16 +29,7 @@ export function WizardStepLocationServiceSelect({
onPatch,
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}
: {};
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p);

View file

@ -1,5 +1,4 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
@ -9,7 +8,10 @@ import {
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
getClosabilityGaps,
wizardDraftToClosabilityInput,
@ -28,6 +30,7 @@ import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
@ -198,12 +201,57 @@ export function WizardStepVendorTime({
return (
<div className="space-y-5">
<WizardVendorCompanyFields
draft={draft}
vendors={vendors}
selectedVendor={selectedVendor}
onPatch={onPatch}
/>
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{draft.vendorId !== "" && selectedVendor != null && (
<WizardVendorDetail

View file

@ -1,141 +0,0 @@
import { Building2, Wrench } from "lucide-react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
type WizardVendorCompanyFieldsProps = {
draft: WorkOrderWizardDraft;
vendors: VendorDropdownItem[];
selectedVendor: VendorDropdownItem | undefined;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
const ICON_STYLE = { color: "var(--muted-foreground)" };
function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()}
</span>
);
}
/** Company-first vendor selection with inline technician registration (prototype parity). */
export function WizardVendorCompanyFields({
draft,
vendors,
selectedVendor,
onPatch,
}: WizardVendorCompanyFieldsProps) {
const company = draft.vendorCompany || selectedVendor?.companyName || "";
const companyOptions = buildCompanyOptions(vendors, company);
const technicianOptions = vendors
.filter((vendor) => vendor.companyName === company)
.map((vendor) => ({
key: String(vendor.id),
label: vendor.contactName.trim() || vendor.companyName,
}));
return (
<>
<div>
<WizLabelRow
optional
onClear={
company === ""
? undefined
: () =>
onPatch({
vendorCompany: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
})
}
>
Company
</WizLabelRow>
<WizardFieldSelect
value={company}
placeholder="Select company…"
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
onPick={(nextCompany) =>
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
disabled={company === ""}
icon={
selectedVendor ? (
<TechnicianAvatar vendor={selectedVendor} />
) : (
<Wrench size={16} className="shrink-0" style={ICON_STYLE} />
)
}
searchPlaceholder="Search technician…"
options={technicianOptions}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorCompany: vendor?.companyName ?? company,
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{company !== "" && (
<WizardVendorNewTechnicians
technicians={draft.newTechnicians ?? []}
onChange={(newTechnicians) => onPatch({ newTechnicians })}
/>
)}
</>
);
}

View file

@ -1,96 +0,0 @@
import { X } from "lucide-react";
import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardVendorNewTechniciansProps = {
technicians: WizardNewTechnician[];
onChange: (technicians: WizardNewTechnician[]) => void;
};
/**
* Inline technician registration on the wizard's Vendor & time step.
* Rows stay local to the draft; they are persisted under the selected company only
* when the work order is created, so cancelling the wizard never creates a vendor.
*/
export function WizardVendorNewTechnicians({
technicians,
onChange,
}: WizardVendorNewTechniciansProps) {
const update = (index: number, patch: Partial<WizardNewTechnician>) =>
onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<>
{technicians.map((technician, index) => {
const label = `New Technician ${index + 1}`;
return (
<div
key={index}
role="group"
aria-label={label}
style={{
padding: 10,
borderRadius: "var(--radius-md)",
background: "var(--color-bg-muted)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: "var(--color-text-muted)" }}
>
{label}
</span>
<button
type="button"
title="Remove technician"
aria-label={`Remove ${label}`}
onClick={() => onChange(technicians.filter((_, i) => i !== index))}
style={{ color: "var(--color-text-muted)" }}
>
<X size={13} aria-hidden />
</button>
</div>
<div className="flex flex-col gap-2">
<div>
<WizSubLabel>Technician name</WizSubLabel>
<input
aria-label={`${label} name`}
value={technician.name}
onChange={(e) => update(index, { name: e.target.value })}
placeholder="Technician name"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizSubLabel>Technician phone</WizSubLabel>
<input
type="tel"
aria-label={`${label} phone`}
value={technician.phone}
onChange={(e) => update(index, { phone: e.target.value })}
placeholder="(000) 000-0000"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={() => onChange([...technicians, { name: "", phone: "" }])}
className="text-left text-[12px] font-medium"
style={{ color: "var(--color-primary)" }}
>
+ Add technician
</button>
</>
);
}

View file

@ -162,22 +162,11 @@ export function createCompDocDialogActions({
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// authoritative state, but do not re-run the Generate persist — that patch would carry the
// pre-upload row version and 409, surfacing an error right after a successful upload.
function markSignedPdfUploaded() {
setGeneratedAt(fmtDocNow());
setDocState("generated");
refreshAuthoritativeState();
}
return {
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
markSignedPdfUploaded,
};
}

View file

@ -2,7 +2,6 @@ import { NavLink } from "react-router";
import { Stack } from "@mui/material";
const SETTINGS_LINKS = [
{ path: "/settings/services", label: "Services" },
{ path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" },
] as const;

View file

@ -1,48 +1,18 @@
import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { apiGet } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import {
mapCompletionDocTemplates,
mapService,
mapServices,
} from "@/domain/services/mappers/service-mapper";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { mapServices } from "@/domain/services/mappers/service-mapper";
import type { Service } from "@/domain/services/types/service";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
const SERVICE_WORK_ORDER_TYPE_API = {
PM: 2,
Emergency: 3,
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
};
function toApiInput(input: ServiceInput) {
return {
...input,
supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
(type) => SERVICE_WORK_ORDER_TYPE_API[type],
),
};
}
export const servicesApi = {
getAll: async (isActive?: boolean): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: isActive === undefined ? undefined : { isActive },
});
return mapServices(handleApiResponse(data));
},
getActive: async (): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true },
@ -57,23 +27,4 @@ export const servicesApi = {
});
return mapServices(handleApiResponse(data));
},
getCompletionTemplates: async (): Promise<CompletionDocTemplateOption[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates);
return mapCompletionDocTemplates(handleApiResponse(data));
},
create: async (input: ServiceInput): Promise<Service> => {
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},
deactivate: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.services.deactivate(id));
},
};

View file

@ -1,77 +1,62 @@
import type {
CompletionDocTemplateOption,
LinkedServiceTemplate,
Service,
ServiceWorkOrderType,
} from "@/domain/services/types/service";
import type { Service } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
function readId(item: Record<string, unknown>): string | number {
const value = item.id ?? item.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readString(item: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
const value = item[key];
if (typeof value === "string") return value.trim();
if (typeof value === "number") return String(value);
}
return "";
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
const value = item[key];
if (typeof value === "boolean") return value;
}
return false;
}
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
function mapWorkOrderType(value: unknown): WOType | null {
if (typeof value === "number") {
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
}
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
function mapTemplate(raw: unknown): LinkedServiceTemplate | null {
if (!raw) return null;
const record = asRecord(raw);
const id = readId(record);
return id === "" ? null : { id, name: readString(record, "name", "Name") };
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
return null;
}
export function mapService(raw: unknown): Service {
const record = asRecord(raw);
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
const item = asRecord(raw);
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
return {
id: readId(record),
name: readString(record, "name", "Name"),
trade: readString(record, "trade", "Trade"),
iconKey: readString(record, "iconKey", "IconKey"),
id: readId(item),
name: readString(item, "name", "Name"),
trade: readString(item, "trade", "Trade"),
iconKey: readString(item, "iconKey", "IconKey"),
requiresCompletionDocument: readBool(
record,
item,
"requiresCompletionDocument",
"RequiresCompletionDocument",
),
completionDocTemplate:
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
mapTemplate({
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
}),
isActive: readBool(record, "isActive", "IsActive"),
isActive: readBool(item, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => {
const mapped = mapWorkOrderType(value);
@ -82,34 +67,5 @@ export function mapService(raw: unknown): Service {
}
export function mapServices(raw: unknown): Service[] {
const items = asRecord(raw);
const payload = Array.isArray(raw)
? raw
: (items.items ?? items.Items ?? items.data ?? items.Data);
return Array.isArray(payload)
? payload.map(mapService).filter((service) => service.id !== "")
: [];
}
export function mapCompletionDocTemplates(raw: unknown): CompletionDocTemplateOption[] {
const payload = Array.isArray(raw)
? raw
: (() => {
const record = asRecord(raw);
return record.items ?? record.Items ?? record.data ?? record.Data;
})();
return Array.isArray(payload)
? payload.flatMap((item) => {
const record = asRecord(item);
const id = readId(record);
return id === ""
? []
: [
{
id,
name: readString(record, "name", "Name"),
} satisfies CompletionDocTemplateOption,
];
})
: [];
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
}

View file

@ -1,13 +1,4 @@
export const SERVICE_WORK_ORDER_TYPES = ["PM", "Reactive", "Emergency"] as const;
export type ServiceWorkOrderType = (typeof SERVICE_WORK_ORDER_TYPES)[number];
export interface LinkedServiceTemplate {
id: string | number;
name: string;
}
export type CompletionDocTemplateOption = LinkedServiceTemplate;
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export interface Service {
id: string | number;
@ -15,17 +6,6 @@ export interface Service {
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplate: LinkedServiceTemplate | null;
isActive: boolean;
supportedWorkOrderTypes: ServiceWorkOrderType[];
}
export interface ServiceInput {
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplateId: number | null;
supportedWorkOrderTypes: ServiceWorkOrderType[];
isActive?: boolean;
supportedWorkOrderTypes: WOType[];
}

View file

@ -1,283 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useAuthContext } from "@/providers/auth-context";
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
import {
useCreateService,
useCompletionDocTemplates,
useDeactivateService,
useServices,
useUpdateService,
} from "@/domain/services/use-cases/use-services";
export type ServiceStatus = "active" | "inactive";
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
completionDocTemplateId: string | number | null;
};
export const EMPTY_SERVICE_FORM: ServiceForm = {
name: "",
trade: "",
iconKey: "",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
};
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
// the legacy DropdownOptions seed. A trade with no explicit default falls back
// to "wrench" where this map is read.
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
HVAC: "droplets",
"Plumbing & Water Systems": "droplets",
Electrical: "zap",
"General Building & Handyman": "hammer",
};
export function buildServiceInput(
form: ServiceForm,
includeActiveState = false,
): {
input: ServiceInput | null;
error: string;
} {
const name = form.name.trim();
if (!name || !form.trade || !form.iconKey) {
return { input: null, error: "Service Name, Trade, and Icon are required." };
}
if (!form.supportedWorkOrderTypes.length) {
return { input: null, error: "Select at least one Work Order Type." };
}
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
return {
input: null,
error: "Select a completion document template when the document is required.",
};
}
const templateId = form.completionDocTemplateId;
const { isActive, ...editableFields } = form;
return {
input: {
...editableFields,
name,
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
},
error: "",
};
}
function emptyForm(): ServiceForm {
return {
...EMPTY_SERVICE_FORM,
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
};
}
function formFromService(service: Service): ServiceForm {
return {
name: service.name,
trade: service.trade,
iconKey: service.iconKey,
requiresCompletionDocument: service.requiresCompletionDocument,
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
isActive: service.isActive,
};
}
// Owns the add/edit form lifecycle so the top-level controller stays within the
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
// `closeEditor` share the exact instances the controller reports on.
function useServiceEditor(
canonicalTrades: string[],
canDeactivate: boolean,
createService: ReturnType<typeof useCreateService>,
updateService: ReturnType<typeof useUpdateService>,
) {
const [selected, setSelected] = useState<Service | null>(null);
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
const [form, setForm] = useState<ServiceForm>(emptyForm);
const [iconTouched, setIconTouched] = useState(false);
const [formError, setFormError] = useState("");
// Keep the trade stored on the service being edited selectable even if it was
// later deactivated in the canonical Trade catalog, so editing never silently
// blanks the field and forces an unrelated trade change.
const trades = useMemo(() => {
if (form.trade && !canonicalTrades.includes(form.trade)) {
return [form.trade, ...canonicalTrades];
}
return canonicalTrades;
}, [canonicalTrades, form.trade]);
useEffect(() => {
if (!iconTouched && form.trade) {
setForm((current) => ({
...current,
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
}));
}
}, [form.trade, iconTouched]);
const openCreate = () => {
setSelected(null);
setForm(emptyForm());
setIconTouched(false);
setFormError("");
setEditorMode("add");
};
const openEdit = (service: Service) => {
setSelected(service);
setForm(formFromService(service));
setIconTouched(true);
setFormError("");
setEditorMode("edit");
};
const closeEditor = () => {
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
};
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
if (key === "trade") setIconTouched(false);
setFormError("");
setForm((current) => ({ ...current, [key]: value }));
};
const toggleType = (type: ServiceWorkOrderType) => {
setForm((current) => {
const supported = current.supportedWorkOrderTypes.includes(type)
? current.supportedWorkOrderTypes.filter((item) => item !== type)
: [...current.supportedWorkOrderTypes, type];
return { ...current, supportedWorkOrderTypes: supported };
});
};
const save = () => {
const validation = buildServiceInput(form, canDeactivate);
if (!validation.input) {
setFormError(validation.error);
return;
}
const input = validation.input;
if (editorMode === "add") {
createService.mutate(input, {
onSuccess: (created) => {
setEditorMode(null);
setSelected(created);
},
});
} else if (editorMode === "edit" && selected) {
updateService.mutate(
{ id: selected.id, input },
{
onSuccess: (updated) => {
setEditorMode(null);
setSelected(updated);
},
},
);
}
};
return {
selected,
setSelected,
editorMode,
form,
formError,
trades,
openCreate,
openEdit,
closeEditor,
updateForm,
toggleType,
save,
};
}
export function useServicesRegistryController() {
const { user } = useAuthContext();
const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles);
const canDeactivate = isAdminUser(user?.userRoles);
const [status, setStatus] = useState<ServiceStatus>("active");
const [search, setSearch] = useState("");
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const servicesQuery = useServices();
// The backend rejects any Trade outside the canonical TradeCatalog (400
// TradeInvalid), so the picker is sourced from the server-owned canonical
// Trade reference exposed on Vendor/facets — the same list the vendor roster
// form uses — never the editable DropdownOptions "Trade" category, whose
// legacy seed values ("Plumbing", "General", ...) would fail on save.
const facetsQuery = useVendorFacets();
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
const { data: templates = [] } = useCompletionDocTemplates();
const createService = useCreateService();
const updateService = useUpdateService();
const deactivateService = useDeactivateService();
const editor = useServiceEditor(canonicalTrades, canDeactivate, createService, updateService);
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const activeCount = services.filter((service) => service.isActive).length;
const inactiveCount = services.length - activeCount;
const filteredServices = useMemo(() => {
const needle = search.trim().toLowerCase();
return services
.filter((service) => (status === "active" ? service.isActive : !service.isActive))
.filter(
(service) =>
!needle ||
service.name.toLowerCase().includes(needle) ||
service.trade.toLowerCase().includes(needle),
);
}, [search, services, status]);
const confirmDeactivate = () => {
if (!deactivateTarget) return;
deactivateService.mutate(deactivateTarget.id, {
onSuccess: () => {
setDeactivateTarget(null);
editor.setSelected(null);
},
});
};
return {
canManage,
canDeactivate,
status,
setStatus,
search,
setSearch,
selected: editor.selected,
setSelected: editor.setSelected,
editorMode: editor.editorMode,
form: editor.form,
formError: editor.formError,
trades: editor.trades,
templates,
filteredServices,
services,
activeCount,
inactiveCount,
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
openCreate: editor.openCreate,
openEdit: editor.openEdit,
closeEditor: editor.closeEditor,
updateForm: editor.updateForm,
toggleType: editor.toggleType,
save: editor.save,
deactivateTarget,
setDeactivateTarget,
confirmDeactivate,
isSaving: createService.isPending || updateService.isPending,
isDeactivating: deactivateService.isPending,
};
}

View file

@ -1,62 +0,0 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { servicesApi } from "@/domain/services/api/services-api";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { queryKeys } from "@/infra/query-key/query-key";
export function useServices(isActive?: boolean): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.list(isActive),
queryFn: () => servicesApi.getAll(isActive),
staleTime: 60 * 1000,
});
}
export function useCompletionDocTemplates(): UseQueryResult<CompletionDocTemplateOption[], Error> {
return useQuery({
queryKey: queryKeys.services.completionTemplates(),
queryFn: servicesApi.getCompletionTemplates,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInput) => servicesApi.create(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service added");
},
onError: (error: Error) => toast.error(error.message || "Failed to add service"),
});
}
export function useUpdateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string | number; input: ServiceInput }) =>
servicesApi.update(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service updated");
},
onError: (error: Error) => toast.error(error.message || "Failed to update service"),
});
}
export function useDeactivateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => servicesApi.deactivate(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service deactivated");
},
onError: (error: Error) => toast.error(error.message || "Failed to deactivate service"),
});
}

View file

@ -1,96 +0,0 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
export interface NewCompanyTechnician {
contactName: string;
phone: string;
tradeSpecialties?: string;
}
export interface AddTechniciansToCompanyInput {
/** Any persisted vendor (technician) of the target company; resolves the company roster. */
anchorVendorId: string | number;
technicians: NewCompanyTechnician[];
}
function takeAddedTechnician(
added: VendorRosterTechnician[],
contactName: string,
): VendorRosterTechnician {
const index = added.findIndex((technician) => technician.contactName.trim() === contactName);
if (index < 0) {
throw new Error("The technician was saved but could not be matched. Reload and try again.");
}
const [match] = added.splice(index, 1);
return match as VendorRosterTechnician;
}
/**
* Adds technicians to an existing vendor company through the additive roster PATCH.
*
* Work-order entry points used to POST a whole new vendor with blank company
* fields, which the create validator rejects (company phone/email) and which would
* overwrite company data. The additive path only appends technicians, keeps the
* company record untouched, and returns the persisted technician ids.
*/
export async function addTechniciansToCompany({
anchorVendorId,
technicians,
}: AddTechniciansToCompanyInput): Promise<VendorDropdownItem[]> {
const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId });
if (roster.companyId == null || roster.rowVersion === "") {
throw new Error("Vendor company could not be loaded. Try again.");
}
const existingIds = new Set(roster.technicians.map((technician) => String(technician.id)));
const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, {
rowVersion: roster.rowVersion,
addTechnicians: technicians.map((technician) => {
const phone = technician.phone.trim();
return {
contactName: technician.contactName.trim(),
phone,
email: "",
tradeSpecialties: technician.tradeSpecialties ?? "",
isActive: true,
...(phone === "" ? {} : { preferredContact: "Phone" }),
};
}),
});
const added = saved.technicians.filter(
(technician) => technician.id != null && !existingIds.has(String(technician.id)),
);
return technicians.map((technician) => {
const match = takeAddedTechnician(added, technician.contactName.trim());
const email = match.email.trim();
return {
id: match.id ?? "",
companyName: saved.name || roster.name,
contactName: match.contactName,
tradeSpecialties: match.tradeSpecialties,
address: saved.address,
distanceMiles: null,
email: email || undefined,
};
});
}
export function useAddTechniciansToCompany(): UseMutationResult<
VendorDropdownItem[],
Error,
AddTechniciansToCompanyInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addTechniciansToCompany,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
},
});
}

View file

@ -0,0 +1,21 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");
},
});
}

View file

@ -216,14 +216,10 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ throwHttpErrors: false },
{ credentials: "include", throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);

View file

@ -24,11 +24,6 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export type WizardSchedMode = "date" | "week" | "range";
export interface WizardNewTechnician {
name: string;
phone: string;
}
export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
@ -52,10 +47,6 @@ export interface WorkOrderWizardDraft {
avetaRequired: boolean;
vendorId: string;
vendorName: string;
/** Company picked on the Vendor & time step; scopes the technician list. */
vendorCompany?: string;
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
newTechnicians?: WizardNewTechnician[];
techPhone: string;
vendorNotes: string;
apptStart: string;
@ -99,8 +90,6 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
avetaRequired: false,
vendorId: "",
vendorName: "",
vendorCompany: "",
newTechnicians: [],
techPhone: "",
vendorNotes: "",
apptStart: "",

View file

@ -75,8 +75,6 @@ export const queryKeys = {
},
services: {
all: ["services"] as const,
list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
completionTemplates: () => [...queryKeys.services.all, "completionTemplates"] as const,
active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,

View file

@ -6,18 +6,13 @@ export function getPrimaryUserRole(
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
return hasUserRole(userRoles, "admin");
}
export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
if (!userRoles) {
return false;
}
const normalizedExpectedRole = expectedRole.trim().toLowerCase();
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
.includes(normalizedExpectedRole);
.includes("admin");
}
/**

View file

@ -1,153 +0,0 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
import type { Service } from "@/domain/services/types/service";
import { renderWithProviders } from "@/test/test-utils";
const useServices = vi.fn();
const mutate = vi.fn();
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1", userRoles: "admin,scheduler" } }),
};
});
vi.mock("@/domain/services/use-cases/use-services", () => ({
useServices: (...args: unknown[]) => useServices(...args),
useCompletionDocTemplates: () => ({ data: [] }),
useCreateService: () => ({ mutate, isPending: false }),
useUpdateService: () => ({ mutate, isPending: false }),
useDeactivateService: () => ({ mutate, isPending: false }),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({
data: {
companies: [],
trades: ["Plumbing & Water Systems", "Electrical", "HVAC", "General Building & Handyman"],
areas: [],
locations: [],
jobBuckets: [],
},
}),
}));
const activeHvac: Service = {
id: 1,
name: "HVAC Repair",
trade: "HVAC",
iconKey: "zap",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive"],
};
const activeDrain: Service = {
...activeHvac,
id: 2,
name: "Drain Cleaning",
trade: "Plumbing",
};
const inactiveFire: Service = {
...activeHvac,
id: 3,
name: "Fire Suppression",
trade: "Fire Protection",
isActive: false,
};
function setup(services: Service[] = [activeHvac, activeDrain, inactiveFire]) {
useServices.mockReturnValue({
data: services,
isLoading: false,
error: null,
});
return renderWithProviders(<ServicesRegistryView />, { route: "/settings/services" });
}
describe("ServicesRegistryView", () => {
it("renders Active and Inactive tabs with live counts from the unfiltered list", () => {
setup();
expect(useServices).toHaveBeenCalledWith();
expect(screen.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Inactive (1)" })).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByText("3 services · 2 active")).toBeInTheDocument();
expect(screen.getByRole("row", { name: /HVAC Repair/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("switches to the Inactive tab and shows only inactive services", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByRole("row", { name: /Fire Suppression/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 inactive service")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("applies the search within the selected tab and reports the filtered footer count", async () => {
setup();
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "drain");
expect(screen.getByRole("row", { name: /Drain Cleaning/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 active service · filtered")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("shows an icon, message, and try-a-different-search hint when a tab and search combination is empty", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "hvac");
expect(screen.getByTestId("services-empty-icon")).toBeInTheDocument();
expect(screen.getByText("No services found")).toBeInTheDocument();
expect(screen.getByText("Try a different search, or add a new service.")).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("offers only canonical trades and sends the selected canonical trade to the create payload", async () => {
setup();
await userEvent.click(screen.getByRole("button", { name: "Add Service" }));
await userEvent.type(screen.getByRole("textbox", { name: "Service Name" }), "Panel Swap");
await userEvent.click(screen.getByRole("combobox", { name: "Trade" }));
// Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not.
expect(screen.getByRole("option", { name: "Plumbing & Water Systems" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Plumbing" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: "Electrical" }));
const saveButtons = screen.getAllByRole("button", { name: "Add Service" });
await userEvent.click(saveButtons[saveButtons.length - 1]);
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate.mock.calls[0][0]).toMatchObject({ trade: "Electrical" });
});
it("summarizes the filtered count and the overall split in the footer without a search", () => {
setup();
const footer = screen.getByText("2 active services").closest("div");
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getByText("2 active · 1 inactive")).toBeInTheDocument();
expect(screen.queryByText(/filtered/)).not.toBeInTheDocument();
});
});

View file

@ -1,153 +0,0 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SaveHandler = (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
}));
// A completion-doc upload that settles successfully. The backend has already set
// DocStatus=Yes and bumped the work-order row version inside the same transaction,
// so any client follow-up patch would carry a stale version.
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useUploadCompletionDoc: () => ({
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
void options?.onSuccess?.();
},
isPending: false,
}),
}));
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
}));
vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
showCompDocDraftSavedToast: vi.fn(),
}));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
completedDate: "2026-07-01",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Seattle",
locationId: "mock-loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "Vendor Co",
tech: "Tech One",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return { ...defaults, ...overrides };
}
describe("CompDocDialog signed PDF upload (SH-380)", () => {
beforeEach(() => {
vi.mocked(toast.error).mockReset();
});
function uploadSignedPdf(onSave: SaveHandler) {
const onDateCompletedChange = vi.fn().mockResolvedValue(undefined);
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<CompDocDialog
row={baseRow()}
open
onOpenChange={vi.fn()}
onSave={onSave}
onDateCompletedChange={onDateCompletedChange}
/>
</QueryClientProvider>,
);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).not.toBeNull();
fireEvent.change(fileInput!, {
target: {
files: [
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
type: "application/pdf",
}),
],
},
});
return { onSave, onDateCompletedChange };
}
it("does not re-run the docStatus persist after a successful upload", async () => {
// The server already marked the document generated; a client docStatus patch here would
// carry the pre-upload row version and 409. It must not run.
const onSave: SaveHandler = vi.fn(() =>
Promise.reject(new Error("The record was modified by another user. Refresh and retry.")),
);
uploadSignedPdf(onSave);
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onSave).not.toHaveBeenCalled();
});
it("surfaces no error toast to the user when the signed PDF upload settles", async () => {
const onSave: SaveHandler = vi.fn(() => Promise.reject(new Error("conflict")));
const { onDateCompletedChange } = uploadSignedPdf(onSave);
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onDateCompletedChange).not.toHaveBeenCalled();
});
});

View file

@ -1,145 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return { ...actual, useAuthContext: () => ({ user: { id: "u1" } }) };
});
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch).
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
useUploadCompletionDoc: () => ({
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
void options?.onSuccess?.();
},
isPending: false,
}),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",
location: "Seattle",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
company: "Vendor Co",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}
describe("WorkOrderSlideOver signed PDF upload (SH-380)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not issue a docStatus patch or surface an error after a successful upload", async () => {
const row = baseRow();
// A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs.
const onSave = vi.fn(
(
_id: string | number,
_patch: Partial<WorkOrderTableRow>,
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
) => {
options?.onError?.(new Error("The record was modified by another user."));
},
);
render(
<WorkOrderSlideOver
row={row}
editMode={false}
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[row]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("tab", { name: /completion doc/i }));
const pdfInput = document.querySelector('input[accept="application/pdf,.pdf"]');
expect(pdfInput).not.toBeNull();
fireEvent.change(pdfInput!, {
target: {
files: [
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
type: "application/pdf",
}),
],
},
});
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -8,8 +8,8 @@ import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_component
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),

View file

@ -4,8 +4,8 @@ import { VendorDialog } from "@/app/(protected)/workorders/_components/list/tabl
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),

View file

@ -1,13 +1,14 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { Vendor } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
mutateAsync,
isPending: false,
}),
@ -23,19 +24,30 @@ const existingVendor: VendorDropdownItem = {
email: "dispatch@gateway.test",
};
const createdVendor: VendorDropdownItem = {
const createdVendor: Vendor = {
id: 99,
companyId: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
tradeSpecialties: "Plumbing",
email: "dispatch@gateway.test",
phone: "(314) 555-9999",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
distanceMiles: null,
city: "",
state: "",
zip: "",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
};
describe("VendorDialog stable technician identity", () => {
beforeEach(() => {
mutateAsync.mockReset();
mutateAsync.mockResolvedValue([createdVendor]);
mutateAsync.mockResolvedValue(createdVendor);
});
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
@ -78,15 +90,13 @@ describe("VendorDialog stable technician identity", () => {
expect(mutateAsync).toHaveBeenCalledTimes(1);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 10,
technicians: [
expect.objectContaining({
contactName: "Jordan Lee",
phone: "(314) 555-9999",
}),
],
});
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Jordan Lee",
companyName: "Gateway Plumbing",
phone: "(314) 555-9999",
}),
);
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@ -105,7 +115,12 @@ describe("VendorDialog stable technician identity", () => {
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
renderWithProviders(
<VendorDialog
@ -146,7 +161,8 @@ describe("VendorDialog stable technician identity", () => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
contactName: "Casey New",
phone: "3145550188",
}),
);
@ -162,7 +178,12 @@ describe("VendorDialog stable technician identity", () => {
it("keeps a staged draft when the same company is picked again", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
renderWithProviders(
<VendorDialog
@ -194,7 +215,8 @@ describe("VendorDialog stable technician identity", () => {
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
contactName: "Casey New",
phone: "3145550188",
}),
);
});

View file

@ -44,9 +44,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}),
);
});
@ -89,78 +87,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("clears the vendor company and staged inline technicians when the service changes", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
pm: "HVAC",
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
};
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[
{ id: "1", value: "HVAC" },
{ id: "2", value: "Plumbing" },
]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
fireEvent.click(screen.getByRole("button", { name: "Plumbing" }));
expect(onPatch).toHaveBeenCalledWith({
pm: "Plumbing",
serviceId: "2",
extraServices: [],
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("keeps vendor company and staged technicians when the service is unchanged", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
pm: "HVAC",
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
};
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[{ id: "1", value: "HVAC" }]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", serviceId: "1", extraServices: [] });
});
});

View file

@ -1,260 +0,0 @@
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
const vendors: VendorDropdownItem[] = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
distanceMiles: null,
},
{
id: 52,
companyName: "Gateway Plumbing",
contactName: "Casey Pipe",
tradeSpecialties: "Plumbing",
address: "",
distanceMiles: null,
},
];
function draftWith(patch: Partial<WorkOrderWizardDraft>): WorkOrderWizardDraft {
return { ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete", ...patch };
}
describe("WizardStepVendorTime inline technician (SH-364)", () => {
it("hides the add action until a company is picked", () => {
render(
<WizardStepVendorTime
draft={draftWith({})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "+ Add technician" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled();
});
it("stages a new technician row for the picked company", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({ vendorCompany: "Vinewood LLC" })}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "+ Add technician" }));
expect(onPatch).toHaveBeenCalledWith({ newTechnicians: [{ name: "", phone: "" }] });
});
it("scopes technicians to the picked company", () => {
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /select technician/i }));
expect(screen.getByRole("button", { name: "Adam Whyte" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Casey Pipe" })).not.toBeInTheDocument();
});
it("clears the picked vendor and staged technicians when the company changes", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0100",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" }));
expect(onPatch).toHaveBeenCalledWith({
vendorCompany: "Gateway Plumbing",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("preserves the picked vendor and staged technicians when the same company is re-picked", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0100",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
const companyOptions = screen.getAllByRole("button", { name: "Vinewood LLC" });
fireEvent.click(companyOptions[companyOptions.length - 1]);
expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Vinewood LLC" });
});
});
describe("useWizardCreateWithTechnicians (SH-364)", () => {
beforeEach(() => {
mutateAsync.mockReset();
});
it("creates the work order directly when no technician was staged", () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
const draft = draftWith({ vendorCompany: "Vinewood LLC", newTechnicians: [] });
result.current.mutate(draft);
expect(mutateAsync).not.toHaveBeenCalled();
expect(createMutation.mutate).toHaveBeenCalledWith(draft);
});
it("persists staged technicians first and assigns a single new technician", async () => {
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
const createMutation = { mutate: vi.fn(), isPending: false };
const patchDraft = vi.fn();
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft, createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [
{ name: "Jordan Lee", phone: "(314) 555-0199" },
{ name: " ", phone: "" },
],
}),
);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 41,
technicians: [
{ contactName: "Jordan Lee", phone: "(314) 555-0199", tradeSpecialties: "HVAC" },
],
});
const expectedPatch = {
newTechnicians: [],
vendorId: "99",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0199",
};
expect(patchDraft).toHaveBeenCalledWith(expectedPatch);
expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch));
});
it("blocks Create and skips the roster save when a staged phone is not a valid number", async () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-01" }],
}),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
expect(createMutation.mutate).not.toHaveBeenCalled();
});
it("allows a staged technician with a blank phone, matching the modal path", async () => {
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 41,
technicians: [{ contactName: "Jordan Lee", phone: "", tradeSpecialties: "HVAC" }],
});
expect(createMutation.mutate).toHaveBeenCalled();
});
it("does not create the work order when the technician save fails", async () => {
mutateAsync.mockRejectedValue(new Error("boom"));
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
);
});
expect(createMutation.mutate).not.toHaveBeenCalled();
});
});

View file

@ -1,17 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
import type { ServiceInput } from "@/domain/services/types/service";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPostNoContent = vi.fn();
const apiPut = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPostNoContent: (...args: unknown[]) => apiPostNoContent(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
}));
import { servicesApi } from "@/domain/services/api/services-api";
@ -19,38 +12,6 @@ import { servicesApi } from "@/domain/services/api/services-api";
describe("servicesApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPostNoContent.mockReset();
apiPut.mockReset();
});
it("loads active services and maps the linked completion template", async () => {
apiGet.mockResolvedValue({
items: [
{
Id: 7,
Name: "HVAC inspection",
Trade: "HVAC",
IconKey: "droplets",
RequiresCompletionDocument: true,
CompletionDocTemplate: { Id: 4, Name: "HVAC checklist" },
IsActive: true,
SupportedWorkOrderTypes: [2, 6],
},
],
});
const services = await servicesApi.getAll(true);
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
searchParams: { isActive: true },
});
expect(services[0]).toMatchObject({
id: 7,
name: "HVAC inspection",
completionDocTemplate: { id: 4, name: "HVAC checklist" },
supportedWorkOrderTypes: ["PM", "Reactive"],
});
});
it("loads active services filtered by the selected work-order type", async () => {
@ -84,49 +45,4 @@ describe("servicesApi", () => {
expect(apiGet).not.toHaveBeenCalled();
expect(services).toEqual([]);
});
it("creates, updates, and deactivates through the registry routes", async () => {
const input: ServiceInput = {
name: "Boiler inspection",
trade: "HVAC",
iconKey: "droplets",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM"],
};
apiPost.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
apiPut.mockResolvedValue({
id: 8,
...input,
completionDocTemplateId: 12,
isActive: true,
supportedWorkOrderTypes: [2],
});
apiPostNoContent.mockResolvedValue(undefined);
await servicesApi.create(input);
const updated = await servicesApi.update(8, input);
await servicesApi.deactivate(8);
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
...input,
supportedWorkOrderTypes: [2],
});
expect(apiPostNoContent).toHaveBeenCalledWith(API_PATHS.services.deactivate(8));
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
...input,
supportedWorkOrderTypes: [2],
});
expect(updated.completionDocTemplate).toEqual({ id: 12, name: "" });
});
it("loads active Completion Document Template options from the completion route", async () => {
apiGet.mockResolvedValue([{ Id: 4, Name: "HVAC checklist", IsActive: true }]);
const templates = await servicesApi.getCompletionTemplates();
expect(apiGet).toHaveBeenCalledWith(API_PATHS.workOrder.completionTemplates);
expect(templates).toEqual([{ id: 4, name: "HVAC checklist" }]);
});
});

View file

@ -1,91 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiPatch = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiPatch: (...args: unknown[]) => apiPatch(...args),
}));
import { addTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
const roster = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "office@vinewood.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
describe("addTechniciansToCompany (SH-365)", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPut.mockReset();
apiPatch.mockReset();
apiGet.mockResolvedValue(roster);
apiPatch.mockResolvedValue({
...roster,
rowVersion: "AAAAAAAAB9F=",
technicians: [
...roster.technicians,
{ id: 42, contactName: "Jordan Lee", phone: "(314) 555-0199", email: "", isActive: true },
],
});
});
it("appends the technician through the additive roster PATCH without company fields", async () => {
const created = await addTechniciansToCompany({
anchorVendorId: 41,
technicians: [{ contactName: " Jordan Lee ", phone: "3145550199", tradeSpecialties: "HVAC" }],
});
expect(apiGet).toHaveBeenCalledWith("vendor-company-roster", {
searchParams: { vendorId: "41" },
});
expect(apiPost).not.toHaveBeenCalled();
expect(apiPut).not.toHaveBeenCalled();
expect(apiPatch).toHaveBeenCalledTimes(1);
const [path, body] = apiPatch.mock.calls[0] ?? [];
expect(path).toBe("vendor-company-roster/7");
// No companyFields: an existing company's phone/email/address are never sent or blanked.
expect(body).toEqual({
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
});
expect(created).toEqual([
expect.objectContaining({ id: 42, contactName: "Jordan Lee", companyName: "Vinewood LLC" }),
]);
});
it("fails instead of returning a technician the server did not persist", async () => {
apiPatch.mockResolvedValue(roster);
await expect(
addTechniciansToCompany({
anchorVendorId: 41,
technicians: [{ contactName: "Jordan Lee", phone: "" }],
}),
).rejects.toThrow(/could not be matched/i);
});
});

View file

@ -1022,14 +1022,8 @@ describe("workOrdersApi.getMediaContent", () => {
expect(result).toBe(blob);
expect(apiGetFn).toHaveBeenCalledWith(
API_PATHS.workOrder.mediaContent(10, 12),
expect.objectContaining({ throwHttpErrors: false }),
expect.objectContaining({ credentials: "include", throwHttpErrors: false }),
);
// Regression guard (SH-382): a credentialed cross-origin fetch is rejected by the browser
// against the API's `Access-Control-Allow-Origin: *`, so the content request must not opt
// into credentials mode — otherwise the document never opens. JSDOM cannot enforce CORS, so
// this asserts the request shape rather than reproducing the block.
const [, options] = apiGetFn.mock.calls[0] as [string, Record<string, unknown>];
expect(options).not.toHaveProperty("credentials");
});
it("throws ApiError on 404 without inventing a filename", async () => {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canViewAllDispatchersOnDashboard, hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => {
@ -14,13 +14,6 @@ describe("isAdminUser", () => {
});
});
describe("hasUserRole", () => {
it("finds a role regardless of its position", () => {
expect(hasUserRole("Dispatcher,Scheduler", "Scheduler")).toBe(true);
expect(hasUserRole("Dispatcher", "Scheduler")).toBe(false);
});
});
describe("canViewAllDispatchersOnDashboard", () => {
it("grants the dashboard picker to Admin and Scheduler", () => {
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);