mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
48 commits
61cd859c52
...
f0d58c4214
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0d58c4214 | ||
|
|
7c470d389c | ||
|
|
86be0faeb3 | ||
|
|
de7066d31b | ||
|
|
2c47b4a9ca | ||
|
|
2ccd86d0c1 | ||
|
|
7fdf85fc47 | ||
|
|
7eb7e4a730 | ||
|
|
9bba6402e0 | ||
|
|
59aa7e8bb9 | ||
|
|
7c1243eec3 | ||
|
|
744d7af7bb | ||
|
|
e3afa182d6 | ||
|
|
d7ee290b1d | ||
|
|
4d3dd82afb | ||
|
|
29caecd449 | ||
|
|
e1b03dc151 | ||
|
|
5766616cc0 | ||
|
|
8d50c6f75e | ||
|
|
1a7a4ba58f | ||
|
|
c9562650ca | ||
|
|
e780bafdd8 | ||
|
|
28ff14d457 | ||
|
|
8a0a134238 | ||
|
|
abe28a3a7f | ||
|
|
9ed8099e71 | ||
|
|
361f6b65a2 | ||
|
|
534d7ce4d6 | ||
|
|
bbdf418ee3 | ||
|
|
65d55d7c9f | ||
|
|
ff4ecb37e4 | ||
|
|
0eeae02785 | ||
|
|
0302b2828f | ||
|
|
3dbf3df5d5 | ||
|
|
3011cb1eda | ||
|
|
90a3c24846 | ||
|
|
c40f0c4a37 | ||
|
|
7dd7ea497e | ||
|
|
f39328576e | ||
|
|
2fb5d4a965 | ||
|
|
9c360fc95b | ||
|
|
0b229fd054 | ||
|
|
1c66f8d60e | ||
|
|
b449df97e6 | ||
|
|
0b63d882ab | ||
|
|
c9ab1f3598 | ||
|
|
712d8e0dc6 | ||
|
|
ee95847354 |
41 changed files with 2970 additions and 209 deletions
265
e2e/work-orders/inline-technician.spec.ts
Normal file
265
e2e/work-orders/inline-technician.spec.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -137,6 +137,12 @@ 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/ })
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ 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}`,
|
||||
|
|
@ -167,6 +168,8 @@ export const API_PATHS = {
|
|||
},
|
||||
services: {
|
||||
list: "services",
|
||||
byId: (id: string | number) => `services/${id}`,
|
||||
deactivate: (id: string | number) => `services/${id}/deactivate`,
|
||||
},
|
||||
user: {
|
||||
list: "User",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
5
src/app/(protected)/settings/services.tsx
Normal file
5
src/app/(protected)/settings/services.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
|
||||
|
||||
export default function ServicesPage() {
|
||||
return <ServicesRegistryView />;
|
||||
}
|
||||
|
|
@ -97,12 +97,13 @@ export function CompDocDialog({
|
|||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||
|
||||
// Uploading a signed PDF finalizes through the same Generate flow, so the
|
||||
// completedDate/address prerequisites still persist before docStatus Yes.
|
||||
// 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.
|
||||
const upload = useCompDocPdfUpload({
|
||||
workOrderId: row.id,
|
||||
rowVersion,
|
||||
onUploaded: dialog.handleGenerate,
|
||||
onUploaded: dialog.markSignedPdfUploaded,
|
||||
});
|
||||
|
||||
// A signed PDF on file means the document exists even if docState has not caught up.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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";
|
||||
|
|
@ -37,22 +36,17 @@ type UseWorkOrderSlideOverArgs = {
|
|||
saving?: boolean;
|
||||
};
|
||||
|
||||
type CompletionUploadMutate = (
|
||||
input: { file: File; workOrderVersion: string },
|
||||
options: { onSuccess: () => Promise<void> | void },
|
||||
) => void;
|
||||
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => 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;
|
||||
|
|
@ -60,27 +54,11 @@ function uploadSlideOverCompletionPdf({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
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.");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
function resolveSlideOverSource(
|
||||
|
|
@ -167,7 +145,6 @@ export function useWorkOrderSlideOver({
|
|||
file,
|
||||
row,
|
||||
infoSource,
|
||||
onSave,
|
||||
mutate: uploadCompletionMutation.mutate,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
import {
|
||||
buildCompanyOptions,
|
||||
buildTechnicianOptions,
|
||||
|
|
@ -37,7 +37,7 @@ export function useVendorDialogForm({
|
|||
vendors,
|
||||
onSave,
|
||||
}: UseVendorDialogFormArgs) {
|
||||
const createVendor = useCreateVendor();
|
||||
const addTechnicians = useAddTechniciansToCompany();
|
||||
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,
|
||||
createVendor,
|
||||
addTechnicians,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
|
|
@ -141,7 +141,7 @@ export function useVendorDialogForm({
|
|||
companyVendor,
|
||||
saveError,
|
||||
canSubmit,
|
||||
isPending: createVendor.isPending,
|
||||
isPending: addTechnicians.isPending,
|
||||
handleSave,
|
||||
handleCompanyPick: (nextCompany: string) => {
|
||||
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
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 { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
|
||||
|
||||
type UseVendorDialogSaveArgs = {
|
||||
allVendors: VendorDropdownItem[];
|
||||
|
|
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
|
|||
anchorCompanyName: string;
|
||||
tech: string;
|
||||
phone: string;
|
||||
createVendor: ReturnType<typeof useCreateVendor>;
|
||||
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
|
||||
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,
|
||||
|
|
@ -30,7 +61,7 @@ export function useVendorDialogSave({
|
|||
anchorCompanyName,
|
||||
tech,
|
||||
phone,
|
||||
createVendor,
|
||||
addTechnicians,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
|
|
@ -53,11 +84,12 @@ export function useVendorDialogSave({
|
|||
|
||||
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
|
||||
try {
|
||||
const created = await createVendor.mutateAsync({
|
||||
...pendingPayload,
|
||||
phone: phone.trim() || pendingPayload.phone,
|
||||
saveVendor = await persistDraftTechnician({
|
||||
allVendors,
|
||||
pendingPayload,
|
||||
phone,
|
||||
addTechnicians,
|
||||
});
|
||||
saveVendor = vendorToDropdownItem(created);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create technician";
|
||||
setSaveError(message);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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";
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ export function useNewWoWizardController({
|
|||
}
|
||||
};
|
||||
|
||||
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
|
||||
const createWorkOrder = 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
|
||||
|
|
@ -72,6 +73,11 @@ 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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
|
|||
onPatch,
|
||||
}: WizardStepLocationServiceSelectProps) {
|
||||
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
|
||||
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
|
||||
nextPm !== draft.pm
|
||||
? {
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorCompany: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
}
|
||||
: {};
|
||||
|
||||
const selectSvc = (p: string) => {
|
||||
const selected = services.find((service) => service.value === p);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 {
|
||||
|
|
@ -8,10 +9,7 @@ import {
|
|||
type WorkOrderWizardDraft,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import {
|
||||
formatVendorDistance,
|
||||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
|
|
@ -30,7 +28,6 @@ import {
|
|||
ColorDot,
|
||||
Detail,
|
||||
WizLabel,
|
||||
WizLabelRow,
|
||||
WizSubLabel,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
|
||||
|
|
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
|
|||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<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>
|
||||
<WizardVendorCompanyFields
|
||||
draft={draft}
|
||||
vendors={vendors}
|
||||
selectedVendor={selectedVendor}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
|
||||
{draft.vendorId !== "" && selectedVendor != null && (
|
||||
<WizardVendorDetail
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
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 })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,11 +162,22 @@ 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,48 @@
|
|||
import { apiGet } from "@/api/api";
|
||||
import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapServices } from "@/domain/services/mappers/service-mapper";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import {
|
||||
mapCompletionDocTemplates,
|
||||
mapService,
|
||||
mapServices,
|
||||
} from "@/domain/services/mappers/service-mapper";
|
||||
import type {
|
||||
CompletionDocTemplateOption,
|
||||
Service,
|
||||
ServiceInput,
|
||||
} 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 },
|
||||
|
|
@ -27,4 +57,23 @@ 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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,62 +1,77 @@
|
|||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
CompletionDocTemplateOption,
|
||||
LinkedServiceTemplate,
|
||||
Service,
|
||||
ServiceWorkOrderType,
|
||||
} from "@/domain/services/types/service";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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 {
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number") return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
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 {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapWorkOrderType(value: unknown): WOType | null {
|
||||
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
|
||||
if (typeof value === "number") {
|
||||
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "pm" || normalized === "preventivemaintenance"
|
||||
? "PM"
|
||||
: normalized === "emergency"
|
||||
? "Emergency"
|
||||
: normalized === "reactive"
|
||||
? "Reactive"
|
||||
: null;
|
||||
}
|
||||
return 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") };
|
||||
}
|
||||
|
||||
export function mapService(raw: unknown): Service {
|
||||
const item = asRecord(raw);
|
||||
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
|
||||
const record = asRecord(raw);
|
||||
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
|
||||
return {
|
||||
id: readId(item),
|
||||
name: readString(item, "name", "Name"),
|
||||
trade: readString(item, "trade", "Trade"),
|
||||
iconKey: readString(item, "iconKey", "IconKey"),
|
||||
id: readId(record),
|
||||
name: readString(record, "name", "Name"),
|
||||
trade: readString(record, "trade", "Trade"),
|
||||
iconKey: readString(record, "iconKey", "IconKey"),
|
||||
requiresCompletionDocument: readBool(
|
||||
item,
|
||||
record,
|
||||
"requiresCompletionDocument",
|
||||
"RequiresCompletionDocument",
|
||||
),
|
||||
isActive: readBool(item, "isActive", "IsActive"),
|
||||
completionDocTemplate:
|
||||
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
|
||||
mapTemplate({
|
||||
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
|
||||
}),
|
||||
isActive: readBool(record, "isActive", "IsActive"),
|
||||
supportedWorkOrderTypes: Array.isArray(types)
|
||||
? types.flatMap((value) => {
|
||||
const mapped = mapWorkOrderType(value);
|
||||
|
|
@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
|
|||
}
|
||||
|
||||
export function mapServices(raw: unknown): Service[] {
|
||||
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
|
||||
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,
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
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;
|
||||
|
||||
export interface Service {
|
||||
id: string | number;
|
||||
|
|
@ -6,6 +15,17 @@ export interface Service {
|
|||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplate: LinkedServiceTemplate | null;
|
||||
isActive: boolean;
|
||||
supportedWorkOrderTypes: WOType[];
|
||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||
}
|
||||
|
||||
export interface ServiceInput {
|
||||
name: string;
|
||||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplateId: number | null;
|
||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
62
src/domain/services/use-cases/use-services.ts
Normal file
62
src/domain/services/use-cases/use-services.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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"),
|
||||
});
|
||||
}
|
||||
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal file
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -216,10 +216,14 @@ 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),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
{ throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ 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;
|
||||
|
|
@ -47,6 +52,10 @@ 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;
|
||||
|
|
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
|||
avetaRequired: false,
|
||||
vendorId: "",
|
||||
vendorName: "",
|
||||
vendorCompany: "",
|
||||
newTechnicians: [],
|
||||
techPhone: "",
|
||||
vendorNotes: "",
|
||||
apptStart: "",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ 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,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@ 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("admin");
|
||||
.includes(normalizedExpectedRole);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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-create-vendor", () => ({
|
||||
useCreateVendor: () => ({
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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-create-vendor", () => ({
|
||||
useCreateVendor: () => ({
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
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-create-vendor", () => ({
|
||||
useCreateVendor: () => ({
|
||||
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
|
||||
useAddTechniciansToCompany: () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
|
|
@ -24,30 +23,19 @@ const existingVendor: VendorDropdownItem = {
|
|||
email: "dispatch@gateway.test",
|
||||
};
|
||||
|
||||
const createdVendor: Vendor = {
|
||||
const createdVendor: VendorDropdownItem = {
|
||||
id: 99,
|
||||
companyId: 10,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Jordan Lee",
|
||||
email: "dispatch@gateway.test",
|
||||
phone: "(314) 555-9999",
|
||||
companyPhone: "",
|
||||
googleMapsUrl: "",
|
||||
notes: "",
|
||||
totalJobs: 0,
|
||||
address: "1 Main St",
|
||||
city: "",
|
||||
state: "",
|
||||
zip: "",
|
||||
tradeSpecialties: "Plumbing",
|
||||
isActive: true,
|
||||
preferredContact: "Phone",
|
||||
address: "1 Main St",
|
||||
distanceMiles: null,
|
||||
};
|
||||
|
||||
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 () => {
|
||||
|
|
@ -90,13 +78,15 @@ describe("VendorDialog stable technician identity", () => {
|
|||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactName: "Jordan Lee",
|
||||
companyName: "Gateway Plumbing",
|
||||
phone: "(314) 555-9999",
|
||||
}),
|
||||
);
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
anchorVendorId: 10,
|
||||
technicians: [
|
||||
expect.objectContaining({
|
||||
contactName: "Jordan Lee",
|
||||
phone: "(314) 555-9999",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
|
|
@ -115,12 +105,7 @@ 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",
|
||||
phone: "3145550188",
|
||||
});
|
||||
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
|
||||
|
||||
renderWithProviders(
|
||||
<VendorDialog
|
||||
|
|
@ -161,8 +146,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
|
||||
expect(mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactName: "Casey New",
|
||||
phone: "3145550188",
|
||||
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -178,12 +162,7 @@ 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",
|
||||
phone: "3145550188",
|
||||
});
|
||||
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
|
||||
|
||||
renderWithProviders(
|
||||
<VendorDialog
|
||||
|
|
@ -215,8 +194,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
await waitFor(() => {
|
||||
expect(mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactName: "Casey New",
|
||||
phone: "3145550188",
|
||||
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
|
|||
vendorId: "",
|
||||
vendorName: "",
|
||||
techPhone: "",
|
||||
vendorCompany: "",
|
||||
vendorNotes: "",
|
||||
newTechnicians: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -87,7 +89,78 @@ 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: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
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";
|
||||
|
|
@ -12,6 +19,38 @@ 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 () => {
|
||||
|
|
@ -45,4 +84,49 @@ 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" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
91
src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts
vendored
Normal file
91
src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1022,8 +1022,14 @@ describe("workOrdersApi.getMediaContent", () => {
|
|||
expect(result).toBe(blob);
|
||||
expect(apiGetFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.mediaContent(10, 12),
|
||||
expect.objectContaining({ credentials: "include", throwHttpErrors: false }),
|
||||
expect.objectContaining({ 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 () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { canViewAllDispatchersOnDashboard, hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
|
||||
|
||||
describe("isAdminUser", () => {
|
||||
it("returns true when Admin appears in roles", () => {
|
||||
|
|
@ -14,6 +14,13 @@ 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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue