From dcae91d8f5309117ea95444229e3eec911323701 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 12:25:59 -0300 Subject: [PATCH] fix(vendors): address review feedback --- e2e/vendors/vendor-operations.spec.ts | 101 +++++++++++++++--- playwright.config.ts | 3 + src/app/(protected)/vendors/insights.tsx | 4 +- src/app/(protected)/vendors/preferences.tsx | 36 +++++-- .../_components/work-order-form.tsx | 19 +++- .../api/vendor-operations-api.ts | 27 ++--- .../types/vendor-operations.ts | 10 ++ .../use-cases/use-save-work-order.ts | 31 ++++-- 8 files changed, 185 insertions(+), 46 deletions(-) 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) {