mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(vendors): require truthful appointment bounds
This commit is contained in:
parent
900af790c0
commit
6a282e2e80
5 changed files with 271 additions and 34 deletions
105
e2e/vendors/vendor-operations.spec.ts
vendored
105
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -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<string, unknown> | 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<string, unknown>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box ref={containerRef} role="region" aria-label="Vendor & Time" tabIndex={-1}>
|
||||
<SectionTitle>Vendor & Time</SectionTitle>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Appointment start"
|
||||
type="datetime-local"
|
||||
value={scheduledStartLocal}
|
||||
onChange={(event) => onScheduledStartChange(event.target.value)}
|
||||
error={hasBoundsError}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label="Appointment end"
|
||||
type="datetime-local"
|
||||
value={scheduledEndLocal}
|
||||
onChange={(event) => onScheduledEndChange(event.target.value)}
|
||||
error={hasBoundsError}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
</Stack>
|
||||
<Text variant="error" when={hasBoundsError} className="mt-1">
|
||||
{scheduleBoundsError}
|
||||
</Text>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-2">
|
||||
<Autocomplete
|
||||
options={availableVendors}
|
||||
loading={isLoadingVendors}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
|
@ -29,6 +29,95 @@ import {
|
|||
} from "@/app/(protected)/workorders/_components/work-order-fields";
|
||||
import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section";
|
||||
|
||||
type ScheduleBounds = {
|
||||
kind: "valid" | "invalid" | "open";
|
||||
startIso: string | undefined;
|
||||
endIso: string | undefined;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function computeScheduleBounds(startLocal: string, endLocal: string): ScheduleBounds {
|
||||
const hasStart = startLocal.trim() !== "";
|
||||
const hasEnd = endLocal.trim() !== "";
|
||||
|
||||
if (hasStart && hasEnd) {
|
||||
const start = new Date(startLocal);
|
||||
const end = new Date(endLocal);
|
||||
if (
|
||||
!Number.isNaN(start.getTime()) &&
|
||||
!Number.isNaN(end.getTime()) &&
|
||||
end.getTime() > 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<number | null>(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}
|
||||
/>
|
||||
|
||||
<ClassificationFields
|
||||
|
|
@ -270,7 +372,11 @@ export default function WorkOrderFormPage() {
|
|||
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSaving || isScheduleBoundsInvalid}
|
||||
>
|
||||
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue