mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(vendors): address review feedback
This commit is contained in:
parent
0f261d6c16
commit
dcae91d8f5
8 changed files with 185 additions and 46 deletions
101
e2e/vendors/vendor-operations.spec.ts
vendored
101
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
4
src/app/(protected)/vendors/insights.tsx
vendored
4
src/app/(protected)/vendors/insights.tsx
vendored
|
|
@ -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>
|
||||||
|
|
|
||||||
36
src/app/(protected)/vendors/preferences.tsx
vendored
36
src/app/(protected)/vendors/preferences.tsx
vendored
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue