diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts
index 1115fa07..4d2b4e38 100644
--- a/e2e/vendors/vendor-operations.spec.ts
+++ b/e2e/vendors/vendor-operations.spec.ts
@@ -36,7 +36,7 @@ test.describe("Vendor operations", () => {
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
- action: { label: "Choose vendor", href: "/workorders/501?assignVendor=1" },
+ action: { label: "Choose vendor", href: "/workorders/edit/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
@@ -75,7 +75,7 @@ test.describe("Vendor operations", () => {
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
- "/workorders/501?assignVendor=1",
+ "/workorders/edit/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
"href",
@@ -92,11 +92,12 @@ test.describe("Vendor operations", () => {
const requestedUrls: string[] = [];
await page.route("**/api/vendor-operations/insights*", async (route) => {
requestedUrls.push(route.request().url());
- if (route.request().url().endsWith(".csv")) {
+ const { pathname } = new URL(route.request().url());
+ if (pathname.endsWith(".csv")) {
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
return;
}
- if (route.request().url().endsWith(".pdf")) {
+ if (pathname.endsWith(".pdf")) {
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
return;
}
@@ -118,16 +119,37 @@ test.describe("Vendor operations", () => {
await page.goto("/vendors/insights");
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
- await page.getByLabel("Trade").fill("Plumbing");
- await expect
- .poll(() =>
- requestedUrls.some((url) => new URL(url).searchParams.get("trade") === "Plumbing"),
- )
- .toBe(true);
+ await page.getByLabel("From", { exact: true }).fill("2026-01-01");
+ await page.getByLabel("To", { exact: true }).fill("2026-01-31");
+ await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
+
+ const insightsQueryHas = (name: string, value: string) =>
+ requestedUrls.some(
+ (url) =>
+ new URL(url).pathname.endsWith("/insights") &&
+ new URL(url).searchParams.get(name) === value,
+ );
+ await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true);
+ await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true);
+ await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true);
+
await page.getByRole("button", { name: "Export CSV" }).click();
await page.getByRole("button", { name: "Export PDF" }).click();
- await expect.poll(() => requestedUrls.some((url) => url.endsWith("insights.csv"))).toBe(true);
- await expect.poll(() => requestedUrls.some((url) => url.endsWith("insights.pdf"))).toBe(true);
+
+ const exportRequest = (format: "csv" | "pdf") =>
+ requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`));
+ const exportHasFilters = (url: string | undefined) => {
+ if (!url) return false;
+ const params = new URL(url).searchParams;
+ return (
+ params.get("from") === "2026-01-01" &&
+ params.get("to") === "2026-01-31" &&
+ params.get("trade") === "Plumbing"
+ );
+ };
+
+ await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true);
+ await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true);
});
test("reorders and saves preferred vendors for a site", async ({ page }) => {
@@ -229,7 +251,17 @@ test.describe("Vendor operations", () => {
);
await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
assignment = route.request().postDataJSON();
- await fulfillJson(route, {});
+ await fulfillJson(route, {
+ availabilityStatus: "Available",
+ conflicts: [
+ {
+ workOrderId: 502,
+ workOrderNumber: "WO-502",
+ start: "2026-07-25T12:00:00Z",
+ end: "2026-07-25T14:00:00Z",
+ },
+ ],
+ });
});
await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, {
@@ -263,11 +295,48 @@ test.describe("Vendor operations", () => {
.poll(() => assignment)
.toEqual({
vendorId: 7,
- scheduledStart: "2026-07-25T12:00:00.000Z",
- scheduledEnd: "2026-07-25T14:00:00.000Z",
- estimatedArrivalAt: "2026-07-25T11:30:00.000Z",
+ scheduledStart: "2026-07-25T09:00:00.000Z",
+ scheduledEnd: "2026-07-25T11:00:00.000Z",
+ estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
etaManualOverride: true,
});
+ await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
await expect(page).toHaveURL(/\/workorders\/900$/);
});
+
+ test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({
+ page,
+ }) => {
+ await authenticate(page);
+ await page.route("**/api/Contact/GetLocations", (route) =>
+ fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
+ );
+ await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] }));
+ await page.route("**/api/DropdownOptions/ByCategory/**", (route) =>
+ fulfillJson(route, { data: [] }),
+ );
+ await page.route("**/api/vendor-operations/availability*", (route) =>
+ fulfillJson(route, { data: [] }),
+ );
+ await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
+ fulfillJson(route, {
+ data: {
+ Id: 501,
+ WorkerOrderTitle: "Boiler repair",
+ LocationId: 10,
+ Priority: "Sev 1",
+ Status: "Open",
+ Comments: [],
+ AuditLog: [],
+ Dispatches: [],
+ },
+ }),
+ );
+
+ await page.goto("/workorders/edit/501?assignVendor=1");
+
+ const vendorSection = page.getByRole("region", { name: "Vendor & Time" });
+ await expect(vendorSection).toBeVisible();
+ await expect(vendorSection).toBeFocused();
+ });
});
diff --git a/playwright.config.ts b/playwright.config.ts
index 5a79ae6d..24d54ed6 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -10,6 +10,9 @@ export default defineConfig({
use: {
// Port 4173 isolates Playwright from the app's normal port 3000 server.
baseURL: "http://localhost:4173",
+ // Pin the browser timezone to UTC so date-to-ISO assertions (e.g. the fixed
+ // 09:00-11:00 schedule window) are deterministic regardless of host locale.
+ timezoneId: "UTC",
trace: "on-first-retry",
},
projects: [
diff --git a/src/app/(protected)/vendors/insights.tsx b/src/app/(protected)/vendors/insights.tsx
index 0d78a7d7..348729f8 100644
--- a/src/app/(protected)/vendors/insights.tsx
+++ b/src/app/(protected)/vendors/insights.tsx
@@ -69,13 +69,13 @@ export default function VendorInsightsPage() {
/>
diff --git a/src/app/(protected)/vendors/preferences.tsx b/src/app/(protected)/vendors/preferences.tsx
index b6cba569..8dd1b6da 100644
--- a/src/app/(protected)/vendors/preferences.tsx
+++ b/src/app/(protected)/vendors/preferences.tsx
@@ -20,22 +20,31 @@ import {
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
+const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
+
export default function VendorPreferencesPage() {
const queryClient = useQueryClient();
const [locationId, setLocationId] = useState("");
const [draft, setDraft] = useState([]);
+ const [isDirty, setIsDirty] = useState(false);
const [trade, setTrade] = useState("");
const [vendorId, setVendorId] = useState(null);
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
const queryKey = ["vendor-operations", "site-preferences", locationId];
- const { data = [], error } = useQuery({
+ const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
queryKey,
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
enabled: locationId !== "",
});
- useEffect(() => setDraft(data), [data]);
+ // A background refetch must not clobber in-flight edits, so only adopt server
+ // data while the draft is clean (initial load, site change, or after a save).
+ useEffect(() => {
+ if (!isDirty) {
+ setDraft(serverPreferences);
+ }
+ }, [isDirty, serverPreferences]);
const vendorOptions = useMemo(
() =>
@@ -51,8 +60,10 @@ export default function VendorPreferencesPage() {
locationId,
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
),
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey });
+ onSuccess: (savedPreferences) => {
+ queryClient.setQueryData(queryKey, savedPreferences);
+ setDraft(savedPreferences);
+ setIsDirty(false);
toast.success("Preferred vendors updated");
},
onError: (mutationError: Error) => toast.error(mutationError.message),
@@ -66,6 +77,7 @@ export default function VendorPreferencesPage() {
[next[index], next[target]] = [next[target]!, next[index]!];
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
});
+ setIsDirty(true);
};
const add = () => {
@@ -84,6 +96,18 @@ export default function VendorPreferencesPage() {
},
]);
setVendorId(null);
+ setIsDirty(true);
+ };
+
+ const remove = (index: number) => {
+ setDraft((current) => current.filter((_, row) => row !== index));
+ setIsDirty(true);
+ };
+
+ const selectLocation = (nextLocationId: string | number) => {
+ setLocationId(nextLocationId);
+ setDraft(EMPTY_PREFERENCES);
+ setIsDirty(false);
};
return (
@@ -102,7 +126,7 @@ export default function VendorPreferencesPage() {
options={locations}
getOptionLabel={(option) => option.name}
value={locations.find((location) => location.id === locationId) ?? null}
- onChange={(_event, option) => setLocationId(option?.id ?? "")}
+ onChange={(_event, option) => selectLocation(option?.id ?? "")}
renderInput={(params) => }
/>
@@ -159,7 +183,7 @@ export default function VendorPreferencesPage() {
setDraft((current) => current.filter((_, row) => row !== index))}
+ onClick={() => remove(index)}
>
diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx
index d179115c..538735dc 100644
--- a/src/app/(protected)/workorders/_components/work-order-form.tsx
+++ b/src/app/(protected)/workorders/_components/work-order-form.tsx
@@ -1,8 +1,8 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
-import { Link as RouterLink, useNavigate, useParams } from "react-router";
+import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router";
import {
Alert,
Autocomplete,
@@ -51,13 +51,16 @@ function SectionTitle({ children }: { children: string }) {
export default function WorkOrderFormPage() {
const { id } = useParams();
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const isEdit = Boolean(id);
+ const assignVendorRequested = searchParams.get("assignVendor") === "1";
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const saveMutation = useSaveWorkOrder();
+ const vendorSectionRef = useRef(null);
const [selectedVendorId, setSelectedVendorId] = useState(null);
const [estimatedArrivalAt, setEstimatedArrivalAt] = useState("");
const [etaManualOverride, setEtaManualOverride] = useState(false);
@@ -116,6 +119,16 @@ export default function WorkOrderFormPage() {
const isSaving = saveMutation.isPending;
const isLoading = isEdit && isLoadingDetail;
+ useEffect(() => {
+ if (assignVendorRequested && !isLoading) {
+ const node = vendorSectionRef.current;
+ if (node) {
+ node.scrollIntoView({ behavior: "smooth", block: "start" });
+ node.focus();
+ }
+ }
+ }, [assignVendorRequested, isLoading]);
+
const onSubmit = (values: WorkOrderFormSchemaValues) => {
saveMutation.mutate({
workOrderId: isEdit ? id : undefined,
@@ -231,7 +244,7 @@ export default function WorkOrderFormPage() {
-
+
Vendor & Time
) {
);
}
-async function saveDownload(path: string, fileName: string): Promise {
- const blob = await api.get(`${base}/${path}`).blob();
+type InsightParams = {
+ from?: string;
+ to?: string;
+ locationId?: string | number;
+ trade?: string;
+ vendorId?: string | number;
+};
+
+async function saveDownload(path: string, fileName: string, params: InsightParams): Promise {
+ const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
@@ -48,13 +57,7 @@ export const vendorOperationsApi = {
return response.data ?? response.Data ?? [];
},
- insights: async (params: {
- from?: string;
- to?: string;
- locationId?: string | number;
- trade?: string;
- vendorId?: string | number;
- }): Promise => {
+ insights: async (params: InsightParams): Promise => {
const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>(
`${base}/insights`,
{ searchParams: searchParams(params) },
@@ -62,8 +65,8 @@ export const vendorOperationsApi = {
return response.data ?? response.Data ?? [];
},
- downloadInsights: (format: "csv" | "pdf") =>
- saveDownload(`insights.${format}`, `vendor-insights.${format}`),
+ downloadInsights: (format: "csv" | "pdf", params: InsightParams) =>
+ saveDownload(`insights.${format}`, `vendor-insights.${format}`, params),
sitePreferences: (locationId: string | number) =>
apiGet(`${base}/sites/${locationId}/preferred-vendors`),
@@ -74,5 +77,5 @@ export const vendorOperationsApi = {
) => apiPut(`${base}/sites/${locationId}/preferred-vendors`, preferences),
assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) =>
- apiPut(`${base}/work-orders/${workOrderId}/assignment`, payload),
+ apiPut(`${base}/work-orders/${workOrderId}/assignment`, payload),
};
diff --git a/src/domain/vendor-operations/types/vendor-operations.ts b/src/domain/vendor-operations/types/vendor-operations.ts
index b0348298..246178da 100644
--- a/src/domain/vendor-operations/types/vendor-operations.ts
+++ b/src/domain/vendor-operations/types/vendor-operations.ts
@@ -67,3 +67,13 @@ export interface VendorAssignmentPayload {
estimatedArrivalAt?: string;
etaManualOverride: boolean;
}
+
+export interface VendorAssignmentResult {
+ availabilityStatus: "Available" | "Unavailable" | "Unknown";
+ conflicts: Array<{
+ workOrderId: number;
+ workOrderNumber?: string;
+ start: string;
+ end: string;
+ }>;
+}
diff --git a/src/domain/work-orders/use-cases/use-save-work-order.ts b/src/domain/work-orders/use-cases/use-save-work-order.ts
index fb7ab237..98019b72 100644
--- a/src/domain/work-orders/use-cases/use-save-work-order.ts
+++ b/src/domain/work-orders/use-cases/use-save-work-order.ts
@@ -3,7 +3,10 @@ import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
-import type { VendorAssignmentPayload } from "@/domain/vendor-operations/types/vendor-operations";
+import type {
+ VendorAssignmentPayload,
+ VendorAssignmentResult,
+} from "@/domain/vendor-operations/types/vendor-operations";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import { queryKeys } from "@/infra/query-key/query-key";
@@ -14,6 +17,11 @@ type SaveWorkOrderInput = {
vendorAssignment?: VendorAssignmentPayload;
};
+type SaveWorkOrderResult = {
+ workOrderId: string | number;
+ assignment?: VendorAssignmentResult;
+};
+
class PartialWorkOrderSaveError extends Error {
constructor(
public readonly workOrderId: string | number,
@@ -29,28 +37,37 @@ export function useSaveWorkOrder() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: async ({ workOrderId, values, vendorAssignment }: SaveWorkOrderInput) => {
+ mutationFn: async ({
+ workOrderId,
+ values,
+ vendorAssignment,
+ }: SaveWorkOrderInput): Promise => {
const savedWorkOrder = workOrderId
? await workOrdersApi.update(workOrderId, values)
: await workOrdersApi.create(values);
+ let assignment: VendorAssignmentResult | undefined;
if (vendorAssignment) {
try {
- await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment);
+ assignment = await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment);
} catch (error) {
throw new PartialWorkOrderSaveError(savedWorkOrder.id, error);
}
}
- return savedWorkOrder;
+ return { workOrderId: savedWorkOrder.id, assignment };
},
- onSuccess: (savedWorkOrder) => {
+ onSuccess: ({ workOrderId, assignment }) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
- queryKey: queryKeys.workOrders.detail(savedWorkOrder.id),
+ queryKey: queryKeys.workOrders.detail(workOrderId),
});
toast.success("Work order saved successfully");
- navigate(`/workorders/${savedWorkOrder.id}`);
+ if (assignment?.conflicts.length) {
+ const count = assignment.conflicts.length;
+ toast.warning(`Vendor assigned with ${count} scheduling conflict${count > 1 ? "s" : ""}.`);
+ }
+ navigate(`/workorders/${workOrderId}`);
},
onError: (error) => {
if (error instanceof PartialWorkOrderSaveError) {