diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index 20799b49..97d23975 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -285,6 +285,8 @@ test.describe("Vendor operations", () => { await page.getByLabel("Priority").click(); await page.getByRole("option", { name: "Sev 1" }).click(); await page.getByLabel("Scheduled").fill("2026-07-25"); + await page.getByLabel("Appointment start").fill("2026-07-25T10:00"); + await page.getByLabel("Appointment end").fill("2026-07-25T12:00"); await page.getByLabel("Company + Technician").click(); await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click(); await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30"); @@ -295,8 +297,8 @@ test.describe("Vendor operations", () => { .poll(() => assignment) .toEqual({ vendorId: 7, - scheduledStart: "2026-07-25T09:00:00.000Z", - scheduledEnd: "2026-07-25T11:00:00.000Z", + scheduledStart: "2026-07-25T10:00:00.000Z", + scheduledEnd: "2026-07-25T12:00:00.000Z", estimatedArrivalAt: "2026-07-25T08:30:00.000Z", etaManualOverride: true, }); @@ -371,6 +373,89 @@ test.describe("Vendor operations", () => { await context.close(); }); + test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({ + browser, + }) => { + // America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT. + // The window below straddles the transition: 01:00 is EST (UTC-5) while + // 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no + // fixed-window assumption remains. + const context = await browser.newContext({ timezoneId: "America/New_York" }); + const page = await context.newPage(); + await authenticate(page); + let assignment: Record | undefined; + + 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: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/AddWorkorder", (route) => + fulfillJson(route, { + data: { + Id: 902, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => { + assignment = route.request().postDataJSON() as Record; + await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] }); + }); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 902, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/new"); + await page.getByLabel("Title").fill("Boiler repair"); + await page.getByLabel("Location").click(); + await page.getByRole("option", { name: "St. Louis HQ" }).click(); + await page.getByLabel("Priority").click(); + await page.getByRole("option", { name: "Sev 1" }).click(); + await page.getByLabel("Appointment start").fill("2026-03-08T01:00"); + await page.getByLabel("Appointment end").fill("2026-03-08T04:00"); + await page.getByLabel("Company + Technician").click(); + await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z"); + await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z"); + await context.close(); + }); + test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({ page, }) => { @@ -383,7 +468,18 @@ test.describe("Vendor operations", () => { fulfillJson(route, { data: [] }), ); await page.route("**/api/vendor-operations/availability*", (route) => - fulfillJson(route, { data: [] }), + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), ); await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => fulfillJson(route, { @@ -405,5 +501,8 @@ test.describe("Vendor operations", () => { const vendorSection = page.getByRole("region", { name: "Vendor & Time" }); await expect(vendorSection).toBeVisible(); await expect(vendorSection).toBeFocused(); + await page.getByLabel("Company + Technician").click(); + const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }); + await expect(option).toContainText("Unknown"); }); }); diff --git a/playwright.config.ts b/playwright.config.ts index 24d54ed6..7360b05c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,8 +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. + // Pin the browser timezone to UTC so date-to-ISO assertions in the default + // create flow are deterministic regardless of host locale. DST-transition + // scenarios override this with their own non-UTC context. timezoneId: "UTC", trace: "on-first-retry", }, diff --git a/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx b/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx index 3df1c9ff..92e55413 100644 --- a/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx +++ b/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx @@ -29,6 +29,11 @@ type VendorAndTimeSectionProps = { onEtaChange: (value: string) => void; etaManualOverride: boolean; onOverrideChange: (value: boolean) => void; + scheduledStartLocal: string; + onScheduledStartChange: (value: string) => void; + scheduledEndLocal: string; + onScheduledEndChange: (value: string) => void; + scheduleBoundsError: string | null; }; export function VendorAndTimeSection({ @@ -41,11 +46,40 @@ export function VendorAndTimeSection({ onEtaChange, etaManualOverride, onOverrideChange, + scheduledStartLocal, + onScheduledStartChange, + scheduledEndLocal, + onScheduledEndChange, + scheduleBoundsError, }: VendorAndTimeSectionProps) { + const hasBoundsError = Boolean(scheduleBoundsError); return ( Vendor & Time + onScheduledStartChange(event.target.value)} + error={hasBoundsError} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + onScheduledEndChange(event.target.value)} + error={hasBoundsError} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + + + {scheduleBoundsError} + + start.getTime() + ) { + return { + kind: "valid", + startIso: start.toISOString(), + endIso: end.toISOString(), + error: null, + }; + } + return { + kind: "invalid", + startIso: undefined, + endIso: undefined, + error: "Schedule end must be later than schedule start.", + }; + } + + if (hasStart || hasEnd) { + return { + kind: "invalid", + startIso: undefined, + endIso: undefined, + error: "Provide both a schedule start and end, or leave both blank.", + }; + } + + return { kind: "open", startIso: undefined, endIso: undefined, error: null }; +} + +function useVendorSchedule( + scheduledStartLocal: string, + scheduledEndLocal: string, + locationValue: string, + tradeValue: string, +) { + const scheduleBounds = useMemo( + () => computeScheduleBounds(scheduledStartLocal, scheduledEndLocal), + [scheduledStartLocal, scheduledEndLocal], + ); + + const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({ + queryKey: [ + "vendor-operations", + "availability", + scheduleBounds.startIso ?? null, + scheduleBounds.endIso ?? null, + locationValue, + tradeValue, + ], + queryFn: () => + vendorOperationsApi.availability({ + start: scheduleBounds.startIso, + end: scheduleBounds.endIso, + locationId: locationValue || undefined, + trade: tradeValue || undefined, + }), + enabled: scheduleBounds.kind !== "invalid", + staleTime: 30_000, + }); + + const truthfulVendors = + scheduleBounds.kind === "valid" + ? availableVendors + : availableVendors.map((vendor) => ({ + ...vendor, + availabilityStatus: "Unknown" as const, + conflicts: [], + })); + + return { availableVendors: truthfulVendors, isLoadingVendors, scheduleBounds }; +} + function useWorkOrderFormState() { const { id } = useParams(); const navigate = useNavigate(); @@ -45,6 +134,8 @@ function useWorkOrderFormState() { const [selectedVendorId, setSelectedVendorId] = useState(null); const [estimatedArrivalAt, setEstimatedArrivalAt] = useState(""); const [etaManualOverride, setEtaManualOverride] = useState(false); + const [scheduledStartLocal, setScheduledStartLocal] = useState(""); + const [scheduledEndLocal, setScheduledEndLocal] = useState(""); const { register, @@ -60,18 +151,13 @@ function useWorkOrderFormState() { const tradeValue = useWatch({ control, name: "trade" }); const locationValue = useWatch({ control, name: "locationId" }); - const scheduledValue = useWatch({ control, name: "scheduledDate" }); - const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({ - queryKey: ["vendor-operations", "availability", scheduledValue, locationValue, tradeValue], - queryFn: () => - vendorOperationsApi.availability({ - start: scheduledValue ? new Date(`${scheduledValue}T09:00:00`).toISOString() : undefined, - end: scheduledValue ? new Date(`${scheduledValue}T11:00:00`).toISOString() : undefined, - locationId: locationValue || undefined, - trade: tradeValue || undefined, - }), - staleTime: 30_000, - }); + + const { availableVendors, isLoadingVendors, scheduleBounds } = useVendorSchedule( + scheduledStartLocal, + scheduledEndLocal, + locationValue, + tradeValue, + ); const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", { parent: tradeValue || undefined, @@ -108,6 +194,9 @@ function useWorkOrderFormState() { }, [assignVendorRequested, isLoading]); const onSubmit = (values: WorkOrderFormSchemaValues) => { + if (scheduleBounds.kind === "invalid") { + return; + } saveMutation.mutate({ workOrderId: isEdit ? id : undefined, values, @@ -116,12 +205,8 @@ function useWorkOrderFormState() { ? undefined : { vendorId: selectedVendorId, - scheduledStart: scheduledValue - ? new Date(`${scheduledValue}T09:00:00`).toISOString() - : undefined, - scheduledEnd: scheduledValue - ? new Date(`${scheduledValue}T11:00:00`).toISOString() - : undefined, + scheduledStart: scheduleBounds.startIso, + scheduledEnd: scheduleBounds.endIso, estimatedArrivalAt: estimatedArrivalAt ? new Date(estimatedArrivalAt).toISOString() : undefined, @@ -161,6 +246,12 @@ function useWorkOrderFormState() { setEstimatedArrivalAt, etaManualOverride, setEtaManualOverride, + scheduledStartLocal, + setScheduledStartLocal, + scheduledEndLocal, + setScheduledEndLocal, + scheduleBoundsError: scheduleBounds.error, + isScheduleBoundsInvalid: scheduleBounds.kind === "invalid", onSubmit, handleCancel, }; @@ -194,6 +285,12 @@ export default function WorkOrderFormPage() { setEstimatedArrivalAt, etaManualOverride, setEtaManualOverride, + scheduledStartLocal, + setScheduledStartLocal, + scheduledEndLocal, + setScheduledEndLocal, + scheduleBoundsError, + isScheduleBoundsInvalid, onSubmit, handleCancel, } = useWorkOrderFormState(); @@ -245,6 +342,11 @@ export default function WorkOrderFormPage() { onEtaChange={setEstimatedArrivalAt} etaManualOverride={etaManualOverride} onOverrideChange={setEtaManualOverride} + scheduledStartLocal={scheduledStartLocal} + onScheduledStartChange={setScheduledStartLocal} + scheduledEndLocal={scheduledEndLocal} + onScheduledEndChange={setScheduledEndLocal} + scheduleBoundsError={scheduleBoundsError} /> Cancel - 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 98019b72..591031b5 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 @@ -23,12 +23,9 @@ type SaveWorkOrderResult = { }; class PartialWorkOrderSaveError extends Error { - constructor( - public readonly workOrderId: string | number, - cause: unknown, - ) { - const detail = cause instanceof Error ? cause.message : "Unknown assignment error"; - super(`Work order saved, but vendor assignment failed: ${detail}`); + constructor(public readonly workOrderId: string | number) { + super("Work order saved, but the vendor assignment could not be completed."); + this.name = "PartialWorkOrderSaveError"; } } @@ -50,8 +47,8 @@ export function useSaveWorkOrder() { if (vendorAssignment) { try { assignment = await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment); - } catch (error) { - throw new PartialWorkOrderSaveError(savedWorkOrder.id, error); + } catch { + throw new PartialWorkOrderSaveError(savedWorkOrder.id); } } @@ -80,7 +77,7 @@ export function useSaveWorkOrder() { return; } - toast.error(error.message || "Failed to save work order"); + toast.error("Unable to save the work order. Please try again."); }, }); }