fix(vendors): address review feedback

This commit is contained in:
Alexandre Brandizzi 2026-07-24 12:25:59 -03:00
parent 0f261d6c16
commit dcae91d8f5
8 changed files with 185 additions and 46 deletions

View file

@ -36,7 +36,7 @@ test.describe("Vendor operations", () => {
workOrderNumber: "WO-501", workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z", triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12: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", 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("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute( await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href", "href",
"/workorders/501?assignVendor=1", "/workorders/edit/501?assignVendor=1",
); );
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute( await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
"href", "href",
@ -92,11 +92,12 @@ test.describe("Vendor operations", () => {
const requestedUrls: string[] = []; const requestedUrls: string[] = [];
await page.route("**/api/vendor-operations/insights*", async (route) => { await page.route("**/api/vendor-operations/insights*", async (route) => {
requestedUrls.push(route.request().url()); 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" }); await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
return; return;
} }
if (route.request().url().endsWith(".pdf")) { if (pathname.endsWith(".pdf")) {
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" }); await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
return; return;
} }
@ -118,16 +119,37 @@ test.describe("Vendor operations", () => {
await page.goto("/vendors/insights"); await page.goto("/vendors/insights");
await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible(); await expect(page.getByRole("cell", { name: "Gateway Plumbing" })).toBeVisible();
await page.getByLabel("Trade").fill("Plumbing"); await page.getByLabel("From", { exact: true }).fill("2026-01-01");
await expect await page.getByLabel("To", { exact: true }).fill("2026-01-31");
.poll(() => await page.getByLabel("Trade", { exact: true }).fill("Plumbing");
requestedUrls.some((url) => new URL(url).searchParams.get("trade") === "Plumbing"),
) const insightsQueryHas = (name: string, value: string) =>
.toBe(true); 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 CSV" }).click();
await page.getByRole("button", { name: "Export PDF" }).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 }) => { 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) => { await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => {
assignment = route.request().postDataJSON(); 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) => await page.route("**/api/WorkOrder/GetWorkorderById*", (route) =>
fulfillJson(route, { fulfillJson(route, {
@ -263,11 +295,48 @@ test.describe("Vendor operations", () => {
.poll(() => assignment) .poll(() => assignment)
.toEqual({ .toEqual({
vendorId: 7, vendorId: 7,
scheduledStart: "2026-07-25T12:00:00.000Z", scheduledStart: "2026-07-25T09:00:00.000Z",
scheduledEnd: "2026-07-25T14:00:00.000Z", scheduledEnd: "2026-07-25T11:00:00.000Z",
estimatedArrivalAt: "2026-07-25T11:30:00.000Z", estimatedArrivalAt: "2026-07-25T08:30:00.000Z",
etaManualOverride: true, etaManualOverride: true,
}); });
await expect(page.getByText(/scheduling conflict/i)).toBeVisible();
await expect(page).toHaveURL(/\/workorders\/900$/); 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();
});
}); });

View file

@ -10,6 +10,9 @@ export default defineConfig({
use: { use: {
// Port 4173 isolates Playwright from the app's normal port 3000 server. // Port 4173 isolates Playwright from the app's normal port 3000 server.
baseURL: "http://localhost:4173", 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", trace: "on-first-retry",
}, },
projects: [ projects: [

View file

@ -69,13 +69,13 @@ export default function VendorInsightsPage() {
/> />
<Button <Button
variant="outlined" variant="outlined"
onClick={() => void vendorOperationsApi.downloadInsights("csv")} onClick={() => void vendorOperationsApi.downloadInsights("csv", params)}
> >
Export CSV Export CSV
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => void vendorOperationsApi.downloadInsights("pdf")} onClick={() => void vendorOperationsApi.downloadInsights("pdf", params)}
> >
Export PDF Export PDF
</Button> </Button>

View file

@ -20,22 +20,31 @@ import {
useWorkOrderVendors, useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail"; } from "@/domain/work-orders/use-cases/use-work-order-detail";
const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
export default function VendorPreferencesPage() { export default function VendorPreferencesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [locationId, setLocationId] = useState<string | number>(""); const [locationId, setLocationId] = useState<string | number>("");
const [draft, setDraft] = useState<SiteVendorPreference[]>([]); const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
const [isDirty, setIsDirty] = useState(false);
const [trade, setTrade] = useState(""); const [trade, setTrade] = useState("");
const [vendorId, setVendorId] = useState<number | null>(null); const [vendorId, setVendorId] = useState<number | null>(null);
const { data: locations = [] } = useWorkOrderLocations(); const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined); const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
const queryKey = ["vendor-operations", "site-preferences", locationId]; const queryKey = ["vendor-operations", "site-preferences", locationId];
const { data = [], error } = useQuery({ const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
queryKey, queryKey,
queryFn: () => vendorOperationsApi.sitePreferences(locationId), queryFn: () => vendorOperationsApi.sitePreferences(locationId),
enabled: 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( const vendorOptions = useMemo(
() => () =>
@ -51,8 +60,10 @@ export default function VendorPreferencesPage() {
locationId, locationId,
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })), draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
), ),
onSuccess: () => { onSuccess: (savedPreferences) => {
void queryClient.invalidateQueries({ queryKey }); queryClient.setQueryData(queryKey, savedPreferences);
setDraft(savedPreferences);
setIsDirty(false);
toast.success("Preferred vendors updated"); toast.success("Preferred vendors updated");
}, },
onError: (mutationError: Error) => toast.error(mutationError.message), onError: (mutationError: Error) => toast.error(mutationError.message),
@ -66,6 +77,7 @@ export default function VendorPreferencesPage() {
[next[index], next[target]] = [next[target]!, next[index]!]; [next[index], next[target]] = [next[target]!, next[index]!];
return next.map((item, sortOrder) => ({ ...item, sortOrder })); return next.map((item, sortOrder) => ({ ...item, sortOrder }));
}); });
setIsDirty(true);
}; };
const add = () => { const add = () => {
@ -84,6 +96,18 @@ export default function VendorPreferencesPage() {
}, },
]); ]);
setVendorId(null); 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 ( return (
@ -102,7 +126,7 @@ export default function VendorPreferencesPage() {
options={locations} options={locations}
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
value={locations.find((location) => location.id === locationId) ?? null} value={locations.find((location) => location.id === locationId) ?? null}
onChange={(_event, option) => setLocationId(option?.id ?? "")} onChange={(_event, option) => selectLocation(option?.id ?? "")}
renderInput={(params) => <TextField {...params} label="Site" />} renderInput={(params) => <TextField {...params} label="Site" />}
/> />
</Paper> </Paper>
@ -159,7 +183,7 @@ export default function VendorPreferencesPage() {
</IconButton> </IconButton>
<IconButton <IconButton
aria-label={`Remove ${item.vendorName}`} aria-label={`Remove ${item.vendorName}`}
onClick={() => setDraft((current) => current.filter((_, row) => row !== index))} onClick={() => remove(index)}
> >
<Trash2 size={18} /> <Trash2 size={18} />
</IconButton> </IconButton>

View file

@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Controller, useForm, useWatch } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { import {
Alert, Alert,
Autocomplete, Autocomplete,
@ -51,13 +51,16 @@ function SectionTitle({ children }: { children: string }) {
export default function WorkOrderFormPage() { export default function WorkOrderFormPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
const assignVendorRequested = searchParams.get("assignVendor") === "1";
const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id); const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id);
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations(); const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown(); const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem"); const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const saveMutation = useSaveWorkOrder(); const saveMutation = useSaveWorkOrder();
const vendorSectionRef = useRef<HTMLDivElement>(null);
const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null); const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null);
const [estimatedArrivalAt, setEstimatedArrivalAt] = useState(""); const [estimatedArrivalAt, setEstimatedArrivalAt] = useState("");
const [etaManualOverride, setEtaManualOverride] = useState(false); const [etaManualOverride, setEtaManualOverride] = useState(false);
@ -116,6 +119,16 @@ export default function WorkOrderFormPage() {
const isSaving = saveMutation.isPending; const isSaving = saveMutation.isPending;
const isLoading = isEdit && isLoadingDetail; 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) => { const onSubmit = (values: WorkOrderFormSchemaValues) => {
saveMutation.mutate({ saveMutation.mutate({
workOrderId: isEdit ? id : undefined, workOrderId: isEdit ? id : undefined,
@ -231,7 +244,7 @@ export default function WorkOrderFormPage() {
</Stack> </Stack>
</Box> </Box>
<Box> <Box ref={vendorSectionRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
<SectionTitle>Vendor & Time</SectionTitle> <SectionTitle>Vendor & Time</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}> <Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<Autocomplete <Autocomplete

View file

@ -1,6 +1,7 @@
import { api, apiGet, apiPut } from "@/api/api"; import { api, apiGet, apiPut } from "@/api/api";
import type { import type {
SiteVendorPreference, SiteVendorPreference,
VendorAssignmentResult,
VendorAvailability, VendorAvailability,
VendorAssignmentPayload, VendorAssignmentPayload,
VendorInsight, VendorInsight,
@ -17,8 +18,16 @@ function searchParams(values: Record<string, string | number | undefined>) {
); );
} }
async function saveDownload(path: string, fileName: string): Promise<void> { type InsightParams = {
const blob = await api.get(`${base}/${path}`).blob(); from?: string;
to?: string;
locationId?: string | number;
trade?: string;
vendorId?: string | number;
};
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
@ -48,13 +57,7 @@ export const vendorOperationsApi = {
return response.data ?? response.Data ?? []; return response.data ?? response.Data ?? [];
}, },
insights: async (params: { insights: async (params: InsightParams): Promise<VendorInsight[]> => {
from?: string;
to?: string;
locationId?: string | number;
trade?: string;
vendorId?: string | number;
}): Promise<VendorInsight[]> => {
const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>( const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>(
`${base}/insights`, `${base}/insights`,
{ searchParams: searchParams(params) }, { searchParams: searchParams(params) },
@ -62,8 +65,8 @@ export const vendorOperationsApi = {
return response.data ?? response.Data ?? []; return response.data ?? response.Data ?? [];
}, },
downloadInsights: (format: "csv" | "pdf") => downloadInsights: (format: "csv" | "pdf", params: InsightParams) =>
saveDownload(`insights.${format}`, `vendor-insights.${format}`), saveDownload(`insights.${format}`, `vendor-insights.${format}`, params),
sitePreferences: (locationId: string | number) => sitePreferences: (locationId: string | number) =>
apiGet<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`), apiGet<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`),
@ -74,5 +77,5 @@ export const vendorOperationsApi = {
) => apiPut<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`, preferences), ) => apiPut<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`, preferences),
assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) => assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) =>
apiPut<void>(`${base}/work-orders/${workOrderId}/assignment`, payload), apiPut<VendorAssignmentResult>(`${base}/work-orders/${workOrderId}/assignment`, payload),
}; };

View file

@ -67,3 +67,13 @@ export interface VendorAssignmentPayload {
estimatedArrivalAt?: string; estimatedArrivalAt?: string;
etaManualOverride: boolean; etaManualOverride: boolean;
} }
export interface VendorAssignmentResult {
availabilityStatus: "Available" | "Unavailable" | "Unknown";
conflicts: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}

View file

@ -3,7 +3,10 @@ import { useNavigate } from "react-router";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; 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 { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema"; import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
@ -14,6 +17,11 @@ type SaveWorkOrderInput = {
vendorAssignment?: VendorAssignmentPayload; vendorAssignment?: VendorAssignmentPayload;
}; };
type SaveWorkOrderResult = {
workOrderId: string | number;
assignment?: VendorAssignmentResult;
};
class PartialWorkOrderSaveError extends Error { class PartialWorkOrderSaveError extends Error {
constructor( constructor(
public readonly workOrderId: string | number, public readonly workOrderId: string | number,
@ -29,28 +37,37 @@ export function useSaveWorkOrder() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ workOrderId, values, vendorAssignment }: SaveWorkOrderInput) => { mutationFn: async ({
workOrderId,
values,
vendorAssignment,
}: SaveWorkOrderInput): Promise<SaveWorkOrderResult> => {
const savedWorkOrder = workOrderId const savedWorkOrder = workOrderId
? await workOrdersApi.update(workOrderId, values) ? await workOrdersApi.update(workOrderId, values)
: await workOrdersApi.create(values); : await workOrdersApi.create(values);
let assignment: VendorAssignmentResult | undefined;
if (vendorAssignment) { if (vendorAssignment) {
try { try {
await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment); assignment = await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment);
} catch (error) { } catch (error) {
throw new PartialWorkOrderSaveError(savedWorkOrder.id, 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.all });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(savedWorkOrder.id), queryKey: queryKeys.workOrders.detail(workOrderId),
}); });
toast.success("Work order saved successfully"); 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) => { onError: (error) => {
if (error instanceof PartialWorkOrderSaveError) { if (error instanceof PartialWorkOrderSaveError) {