feat(vendors): complete operations roadmap frontend

This commit is contained in:
Alexandre Brandizzi 2026-07-23 19:18:06 -03:00
parent 922d6c9a57
commit ac4ecac9f0
18 changed files with 1354 additions and 15 deletions

190
e2e/vendors/vendor-operations.spec.ts vendored Normal file
View file

@ -0,0 +1,190 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const TEST_USER = {
token: "vendor-operations-e2e-token",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Vendor Operations Admin",
id: "1",
};
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
async function authenticate(page: Page) {
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
}
test.describe("Vendor operations", () => {
test("groups 48-hour reminders and overlap conflicts with direct work-order actions", async ({
page,
}) => {
await authenticate(page);
await page.route("**/api/vendor-operations/notifications", (route) =>
fulfillJson(route, {
items: [
{
id: "no-vendor-501",
type: "NoVendor",
severity: "Medium",
group: "No Vendor",
title: "No vendor assigned — 48h to service",
workOrderId: 501,
workOrderNumber: "WO-501",
triggeredAt: "2026-07-23T12:00:00Z",
scheduledAt: "2026-07-25T12:00:00Z",
action: { label: "Choose vendor", href: "/workorders/501?assignVendor=1" },
},
{
id: "vendor-conflict-801-802",
type: "VendorConflict",
severity: "Medium",
group: "Vendor Conflict",
title: "Vendor has overlapping appointments",
triggeredAt: "2026-07-23T12:00:00Z",
workOrders: [
{
workOrderId: 501,
workOrderNumber: "WO-501",
start: "2026-07-25T12:00:00Z",
end: "2026-07-25T14:00:00Z",
},
{
workOrderId: 502,
workOrderNumber: "WO-502",
start: "2026-07-25T13:00:00Z",
end: "2026-07-25T15:00:00Z",
},
],
actions: [
{ label: "WO-501", href: "/workorders/501" },
{ label: "WO-502", href: "/workorders/502" },
],
},
],
}),
);
await page.goto("/notifications");
await expect(page.getByRole("heading", { name: "Items requiring attention" })).toBeVisible();
await expect(page.getByRole("heading", { name: "No Vendor" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendor Conflict" })).toBeVisible();
await expect(page.getByRole("link", { name: "Choose vendor" })).toHaveAttribute(
"href",
"/workorders/501?assignVendor=1",
);
await expect(page.getByRole("link", { name: "WO-501" })).toHaveAttribute(
"href",
"/workorders/501",
);
await expect(page.getByRole("link", { name: "WO-502" })).toHaveAttribute(
"href",
"/workorders/502",
);
});
test("filters insights and exposes both export formats", async ({ page }) => {
await authenticate(page);
const requestedUrls: string[] = [];
await page.route("**/api/vendor-operations/insights*", async (route) => {
requestedUrls.push(route.request().url());
if (route.request().url().endsWith(".csv")) {
await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" });
return;
}
if (route.request().url().endsWith(".pdf")) {
await route.fulfill({ status: 200, contentType: "application/pdf", body: "%PDF-1.4" });
return;
}
await fulfillJson(route, {
data: [
{
vendorId: 7,
vendor: "Gateway Plumbing",
completedJobs: 12,
cancellationRefusalRate: 0.1,
onTimeArrivalRate: 0.9,
onTimeCompletionRate: 0.8,
averageCycleHours: 5.5,
},
],
});
});
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.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);
});
test("reorders and saves preferred vendors for a site", async ({ page }) => {
await authenticate(page);
let savedPreferences: unknown;
await page.route("**/api/Contact/GetLocations", (route) =>
fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }),
);
await page.route("**/api/Vendor/Dropdown*", (route) =>
fulfillJson(route, {
data: [
{ Id: 7, CompanyName: "Gateway Plumbing", TradeSpecialties: "Plumbing" },
{ Id: 8, CompanyName: "Metro HVAC", TradeSpecialties: "HVAC" },
],
}),
);
await page.route("**/api/vendor-operations/sites/10/preferred-vendors", async (route) => {
if (route.request().method() === "PUT") {
savedPreferences = route.request().postDataJSON();
await fulfillJson(route, savedPreferences);
return;
}
await fulfillJson(route, [
{
id: 1,
locationId: 10,
vendorId: 7,
vendorName: "Gateway Plumbing",
trade: "Plumbing",
sortOrder: 0,
isStale: false,
},
{
id: 2,
locationId: 10,
vendorId: 8,
vendorName: "Metro HVAC",
trade: "HVAC",
sortOrder: 1,
isStale: false,
},
]);
});
await page.goto("/vendors/preferences");
await page.getByLabel("Site").click();
await page.getByRole("option", { name: "St. Louis HQ" }).click();
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
await expect(page.getByText(/1\. Metro HVAC/)).toBeVisible();
await page.getByRole("button", { name: "Save preferences" }).click();
await expect
.poll(() => savedPreferences)
.toEqual([
{ vendorId: 8, trade: "HVAC", sortOrder: 0 },
{ vendorId: 7, trade: "Plumbing", sortOrder: 1 },
]);
});
});

View file

@ -0,0 +1,104 @@
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
Typography,
} from "@mui/material";
import { Link } from "react-router";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
export default function NotificationsPage() {
const {
data = [],
isLoading,
error,
} = useQuery({
queryKey: ["vendor-operations", "notifications"],
queryFn: vendorOperationsApi.notifications,
refetchInterval: 30_000,
});
const groups = data.reduce((result, item) => {
const items = result.get(item.group) ?? [];
items.push(item);
result.set(item.group, items);
return result;
}, new Map<string, typeof data>());
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Items requiring attention
</Typography>
<Typography color="text.secondary">
Medium-severity vendor reminders refresh automatically. They do not create banners or
toasts.
</Typography>
</Box>
{isLoading && <CircularProgress aria-label="Loading notifications" />}
{error && <Alert severity="error">{error.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Paper variant="outlined" className="p-6">
<Typography>No vendor alerts require attention.</Typography>
</Paper>
)}
{[...groups.entries()].map(([group, items]) => (
<Paper key={group} variant="outlined" className="p-4">
<Stack direction="row" spacing={1} className="mb-3" sx={{ alignItems: "center" }}>
<Typography variant="h6">{group}</Typography>
<Chip label={items.length} size="small" />
</Stack>
<Stack spacing={2}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" className="p-3">
<Stack
direction={{ xs: "column", md: "row" }}
sx={{ gap: 2, justifyContent: "space-between" }}
>
<Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip label={item.severity} color="warning" size="small" />
<Typography sx={{ fontWeight: 600 }}>{item.title}</Typography>
</Stack>
<Typography color="text.secondary" className="mt-1">
{item.workOrderNumber ??
item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")}
</Typography>
{item.scheduledAt && (
<Typography variant="caption">
Scheduled {new Date(item.scheduledAt).toLocaleString()}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{item.action && (
<Button component={Link} to={item.action.href} variant="contained">
{item.action.label}
</Button>
)}
{item.actions?.map((action) => (
<Button
key={action.href}
component={Link}
to={action.href}
variant="outlined"
>
{action.label}
</Button>
))}
</Stack>
</Stack>
</Paper>
))}
</Stack>
</Paper>
))}
</Box>
);
}

123
src/app/(protected)/vendors/insights.tsx vendored Normal file
View file

@ -0,0 +1,123 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
function percent(value: number) {
return `${(Number(value || 0) * 100).toFixed(1)}%`;
}
export default function VendorInsightsPage() {
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [trade, setTrade] = useState("");
const params = { from: from || undefined, to: to || undefined, trade: trade || undefined };
const {
data = [],
isLoading,
error,
} = useQuery({
queryKey: ["vendor-operations", "insights", params],
queryFn: () => vendorOperationsApi.insights(params),
});
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Vendor performance insights
</Typography>
<Typography color="text.secondary">
Metrics include inactive vendors for historical reporting and use the same filtered
dispatch population.
</Typography>
</Box>
<Paper variant="outlined" className="p-4">
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
label="From"
type="date"
value={from}
onChange={(event) => setFrom(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="To"
type="date"
value={to}
onChange={(event) => setTo(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Trade"
value={trade}
onChange={(event) => setTrade(event.target.value)}
/>
<Button
variant="outlined"
onClick={() => void vendorOperationsApi.downloadInsights("csv")}
>
Export CSV
</Button>
<Button
variant="outlined"
onClick={() => void vendorOperationsApi.downloadInsights("pdf")}
>
Export PDF
</Button>
</Stack>
</Paper>
{isLoading && <CircularProgress aria-label="Loading vendor insights" />}
{error && <Alert severity="error">{error.message}</Alert>}
{!isLoading && !error && data.length === 0 && (
<Alert severity="info">No data matches the selected filters.</Alert>
)}
{data.length > 0 && (
<Paper variant="outlined" sx={{ overflowX: "auto" }}>
<Table aria-label="Vendor performance metrics">
<TableHead>
<TableRow>
<TableCell>Vendor</TableCell>
<TableCell align="right">Completed</TableCell>
<TableCell align="right">Cancellation / refusal</TableCell>
<TableCell align="right">On-time arrival</TableCell>
<TableCell align="right">On-time completion</TableCell>
<TableCell align="right">Avg. cycle</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => (
<TableRow key={row.vendorId}>
<TableCell>{row.vendor}</TableCell>
<TableCell align="right">{row.completedJobs}</TableCell>
<TableCell align="right">{percent(row.cancellationRefusalRate)}</TableCell>
<TableCell align="right">{percent(row.onTimeArrivalRate)}</TableCell>
<TableCell align="right">{percent(row.onTimeCompletionRate)}</TableCell>
<TableCell align="right">
{row.averageCycleHours == null
? "Insufficient data"
: `${Number(row.averageCycleHours).toFixed(1)}h`}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)}
</Box>
);
}

View file

@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Autocomplete,
Box,
Button,
IconButton,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
import { toast } from "react-toastify";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
export default function VendorPreferencesPage() {
const queryClient = useQueryClient();
const [locationId, setLocationId] = useState<string | number>("");
const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
const [trade, setTrade] = useState("");
const [vendorId, setVendorId] = useState<number | null>(null);
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
const queryKey = ["vendor-operations", "site-preferences", locationId];
const { data = [], error } = useQuery({
queryKey,
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
enabled: locationId !== "",
});
useEffect(() => setDraft(data), [data]);
const vendorOptions = useMemo(
() =>
vendors.filter(
(vendor) => !draft.some((preference) => preference.vendorId === Number(vendor.id)),
),
[draft, vendors],
);
const save = useMutation({
mutationFn: () =>
vendorOperationsApi.replaceSitePreferences(
locationId,
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
toast.success("Preferred vendors updated");
},
onError: (mutationError: Error) => toast.error(mutationError.message),
});
const move = (index: number, offset: number) => {
setDraft((current) => {
const target = index + offset;
if (target < 0 || target >= current.length) return current;
const next = [...current];
[next[index], next[target]] = [next[target]!, next[index]!];
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
});
};
const add = () => {
const vendor = vendors.find((item) => Number(item.id) === vendorId);
if (!vendor || locationId === "") return;
setDraft((current) => [
...current,
{
id: -Date.now(),
locationId: Number(locationId),
vendorId: Number(vendor.id),
vendorName: vendor.companyName,
trade: trade.trim() || undefined,
sortOrder: current.length,
isStale: false,
},
]);
setVendorId(null);
};
return (
<Box className="flex flex-col gap-4 p-4">
<Box>
<Typography variant="h5" component="h1">
Preferred vendors by site
</Typography>
<Typography color="text.secondary">
Preferred vendors rank first, but availability, conflict, trade, active-state, and
permission checks still apply.
</Typography>
</Box>
<Paper variant="outlined" className="p-4">
<Autocomplete
options={locations}
getOptionLabel={(option) => option.name}
value={locations.find((location) => location.id === locationId) ?? null}
onChange={(_event, option) => setLocationId(option?.id ?? "")}
renderInput={(params) => <TextField {...params} label="Site" />}
/>
</Paper>
{error && <Alert severity="error">{error.message}</Alert>}
{locationId !== "" && (
<>
<Paper variant="outlined" className="p-4">
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
label="Trade scope (optional)"
value={trade}
onChange={(event) => setTrade(event.target.value)}
/>
<Autocomplete
options={vendorOptions}
getOptionLabel={(option) =>
`${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}`
}
value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null}
onChange={(_event, option) => setVendorId(option ? Number(option.id) : null)}
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
className="flex-1"
/>
<Button variant="contained" onClick={add} disabled={vendorId == null}>
Add
</Button>
</Stack>
</Paper>
<Stack spacing={1}>
{draft.map((item, index) => (
<Paper
key={`${item.vendorId}-${item.trade ?? "all"}`}
variant="outlined"
className="p-3"
>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography className="flex-1">
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
</Typography>
{item.isStale && <Alert severity="warning">Stale preference</Alert>}
<IconButton
aria-label={`Move ${item.vendorName} up`}
onClick={() => move(index, -1)}
disabled={index === 0}
>
<ArrowUp size={18} />
</IconButton>
<IconButton
aria-label={`Move ${item.vendorName} down`}
onClick={() => move(index, 1)}
disabled={index === draft.length - 1}
>
<ArrowDown size={18} />
</IconButton>
<IconButton
aria-label={`Remove ${item.vendorName}`}
onClick={() => setDraft((current) => current.filter((_, row) => row !== index))}
>
<Trash2 size={18} />
</IconButton>
</Stack>
</Paper>
))}
</Stack>
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? "Saving…" : "Save preferences"}
</Button>
</>
)}
</Box>
);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, 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";
@ -9,13 +10,17 @@ import {
Breadcrumbs,
Button,
CircularProgress,
Chip,
FormControlLabel,
Link,
MenuItem,
Paper,
Stack,
TextField,
Typography,
Switch,
} from "@mui/material";
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
import {
@ -33,10 +38,7 @@ import {
useWorkOrderEmployeesDropdown,
useWorkOrderLocations,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useCreateWorkOrder,
useUpdateWorkOrder,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useSaveWorkOrder } from "@/domain/work-orders/use-cases/use-save-work-order";
function SectionTitle({ children }: { children: string }) {
return (
@ -55,8 +57,10 @@ export default function WorkOrderFormPage() {
const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations();
const { data: employees = [] } = useWorkOrderEmployeesDropdown();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const createMutation = useCreateWorkOrder();
const updateMutation = useUpdateWorkOrder(id ?? "");
const saveMutation = useSaveWorkOrder();
const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null);
const [estimatedArrivalAt, setEstimatedArrivalAt] = useState("");
const [etaManualOverride, setEtaManualOverride] = useState(false);
const {
register,
@ -71,6 +75,19 @@ export default function WorkOrderFormPage() {
});
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 { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", {
parent: tradeValue || undefined,
@ -81,6 +98,10 @@ export default function WorkOrderFormPage() {
useEffect(() => {
if (isEdit && detail) {
reset(mapWorkOrderDetailToFormValues(detail) as WorkOrderFormSchemaValues);
const dispatch = detail.dispatches.find((item) => item.status !== "Cancelled");
setSelectedVendorId(dispatch?.vendorId ? Number(dispatch.vendorId) : null);
setEstimatedArrivalAt(dispatch?.estimatedArrivalAt?.slice(0, 16) ?? "");
setEtaManualOverride(Boolean(dispatch?.etaManualOverride));
}
}, [detail, isEdit, reset]);
@ -92,15 +113,30 @@ export default function WorkOrderFormPage() {
const locationOptions = useMemo(() => locations, [locations]);
const isSaving = createMutation.isPending || updateMutation.isPending;
const isSaving = saveMutation.isPending;
const isLoading = isEdit && isLoadingDetail;
const onSubmit = (values: WorkOrderFormSchemaValues) => {
if (isEdit && id) {
updateMutation.mutate(values);
return;
}
createMutation.mutate(values);
const onSubmit = async (values: WorkOrderFormSchemaValues) => {
await saveMutation.mutateAsync({
workOrderId: isEdit ? id : undefined,
values,
vendorAssignment:
selectedVendorId == null
? undefined
: {
vendorId: selectedVendorId,
scheduledStart: scheduledValue
? new Date(`${scheduledValue}T09:00:00`).toISOString()
: undefined,
scheduledEnd: scheduledValue
? new Date(`${scheduledValue}T11:00:00`).toISOString()
: undefined,
estimatedArrivalAt: estimatedArrivalAt
? new Date(estimatedArrivalAt).toISOString()
: undefined,
etaManualOverride,
},
});
};
const handleCancel = () => {
@ -143,7 +179,11 @@ export default function WorkOrderFormPage() {
{isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"}
</Typography>
<Paper variant="outlined" className="p-6">
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
<Box
component="form"
onSubmit={handleSubmit((values) => void onSubmit(values))}
className="flex flex-col gap-8"
>
<Box>
<SectionTitle>Work Order Details</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
@ -195,6 +235,79 @@ export default function WorkOrderFormPage() {
</Stack>
</Box>
<Box>
<SectionTitle>Vendor & Time</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<Autocomplete
options={availableVendors}
loading={isLoadingVendors}
getOptionLabel={(option) =>
`${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}`
}
value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null}
onChange={(_event, option) => setSelectedVendorId(option?.id ?? null)}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Stack className="w-full">
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography>
{option.companyName}
{option.contactName ? ` · ${option.contactName}` : ""}
</Typography>
{option.isPreferred && (
<Chip label="Preferred" color="primary" size="small" />
)}
<Chip
label={option.availabilityStatus}
color={
option.availabilityStatus === "Available"
? "success"
: option.availabilityStatus === "Unavailable"
? "error"
: "default"
}
size="small"
/>
</Stack>
{option.conflicts.length > 0 && (
<Typography variant="caption" color="error">
Conflicts with{" "}
{option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")}
</Typography>
)}
</Stack>
</li>
)}
renderInput={(params) => <TextField {...params} label="Company + Technician" />}
className="flex-1"
/>
<TextField
label="Assignment ETA"
type="datetime-local"
value={estimatedArrivalAt}
onChange={(event) => setEstimatedArrivalAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
className="flex-1"
/>
<FormControlLabel
control={
<Switch
checked={etaManualOverride}
onChange={(event) => setEtaManualOverride(event.target.checked)}
/>
}
label="Manual ETA override"
/>
</Stack>
{selectedVendorId != null &&
availableVendors.find((vendor) => vendor.id === selectedVendorId)
?.availabilityStatus === "Unknown" && (
<Alert severity="warning" className="mt-2">
Availability is unknown. This selection is not treated as confirmed availability.
</Alert>
)}
</Box>
<Box>
<SectionTitle>Classification</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>

View file

@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { Link, useParams } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SignaturePad } from "@/app/v/_components/signature-pad";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import {
@ -482,6 +483,17 @@ export default function VendorPortalDispatchPage() {
<span>${Number(data.nteAmount).toFixed(2)}</span>
</div>
)}
<div className="vp-row">
<strong>Invoice</strong>
<span>
{data.invoiceStatus || "Not Submitted"}
{data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""}
</span>
</div>
<div className="vp-row">
<strong>Payment</strong>
<span>{data.paymentStatus || "Unavailable"}</span>
</div>
{data.scheduledDate && (
<div className="vp-row">
<strong>Scheduled</strong>
@ -587,6 +599,17 @@ export default function VendorPortalDispatchPage() {
/>
</div>
<div className="vp-card">
<h3>Completion documents</h3>
<VendorPortalDocuments
token={token}
dispatchId={dispatchId}
documents={data.documents ?? []}
locked={locked}
onChanged={refresh}
/>
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<UpliftRequestsSection

View file

@ -0,0 +1,158 @@
import { useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
type VendorPortalDocumentsProps = {
token: string;
dispatchId: number;
documents: VendorPortalDocument[];
locked: boolean;
onChanged: () => Promise<void>;
};
type UploadRequest = {
file: File;
replacementId?: number;
};
export function VendorPortalDocuments({
token,
dispatchId,
documents,
locked,
onChanged,
}: VendorPortalDocumentsProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>();
const uploadMutation = useMutation({
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
vendorPortalApi.uploadDocument(token, dispatchId, file, requestedReplacementId, setProgress),
onSuccess: onChanged,
onSettled: () => {
setProgress(undefined);
setReplacementId(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
},
});
const chooseFile = (documentId?: number) => {
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
inputRef.current?.click();
};
const uploadSelectedFile = (file?: File) => {
if (!file) {
return;
}
const validationMessage = validateVendorDocument(file);
if (validationMessage) {
setValidationError(validationMessage);
return;
}
setValidationError(undefined);
setProgress(0);
uploadMutation.mutate({ file, replacementId });
};
const uploadError =
validationError ??
(uploadMutation.error instanceof Error ? uploadMutation.error.message : undefined);
return (
<>
<input
ref={inputRef}
type="file"
aria-label="Choose completion document"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
hidden
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
/>
{documents.length === 0 && (
<VendorPortalFeedback>No completion documents uploaded.</VendorPortalFeedback>
)}
{documents.map((document) => (
<article key={document.id} className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{document.originalFileName} · v{document.version}
</span>
</div>
<div className="vp-row">
<strong>Security scan</strong>
<span>{document.scanStatus}</span>
</div>
<div className="vp-row">
<strong>Review</strong>
<span>{document.reviewStatus}</span>
</div>
{document.rejectionReason && (
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
)}
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!document.canDownload}
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
>
{document.canDownload ? "Download" : "Quarantined"}
</button>
{!locked && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploadMutation.isPending}
onClick={() => chooseFile(document.id)}
>
Replace
</button>
)}
</div>
</article>
))}
{progress !== undefined && (
<div role="status" aria-live="polite">
<progress max={100} value={progress} style={{ width: "100%" }} />
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
</div>
)}
{uploadError && (
<VendorPortalFeedback tone="error" live="assertive">
{uploadError}
</VendorPortalFeedback>
)}
{!locked && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={uploadMutation.isPending}
onClick={() => chooseFile()}
>
Upload document
</button>
</div>
)}
</>
);
}

View file

@ -0,0 +1,23 @@
import type { ReactNode } from "react";
type VendorPortalFeedbackProps = {
children: ReactNode;
tone?: "error" | "muted";
live?: "assertive" | "polite";
};
export function VendorPortalFeedback({
children,
tone = "muted",
live = "polite",
}: VendorPortalFeedbackProps) {
return (
<p
className={tone === "error" ? "vp-error" : "vp-muted"}
role={tone === "error" ? "alert" : "status"}
aria-live={live}
>
{children}
</p>
);
}

View file

@ -6,6 +6,9 @@ import {
ClipboardList,
FileText,
LayoutDashboard,
Bell,
BarChart3,
Star,
} from "lucide-react";
export type NavItemDef = {
@ -34,6 +37,7 @@ export const navGroups: NavGroup[] = [
items: [
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
{ label: "Notifications", icon: Bell, path: "/notifications" },
// { label: "Reports", icon: BarChart2, path: "/reports" },
],
},
@ -42,6 +46,8 @@ export const navGroups: NavGroup[] = [
items: [
{ label: "Vendors", icon: Building2, path: "/vendors" },
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
{ label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
{ label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
],
},
{

View file

@ -0,0 +1,78 @@
import { api, apiGet, apiPut } from "@/api/api";
import type {
SiteVendorPreference,
VendorAvailability,
VendorAssignmentPayload,
VendorInsight,
VendorNotification,
} from "@/domain/vendor-operations/types/vendor-operations";
const base = "vendor-operations";
function searchParams(values: Record<string, string | number | undefined>) {
return Object.fromEntries(
Object.entries(values).filter(
(entry): entry is [string, string | number] => entry[1] !== undefined,
),
);
}
async function saveDownload(path: string, fileName: string): Promise<void> {
const blob = await api.get(`${base}/${path}`).blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
export const vendorOperationsApi = {
notifications: async (): Promise<VendorNotification[]> => {
const response = await apiGet<{ items?: VendorNotification[]; Items?: VendorNotification[] }>(
`${base}/notifications`,
);
return response.items ?? response.Items ?? [];
},
availability: async (params: {
start?: string;
end?: string;
locationId?: string | number;
trade?: string;
}): Promise<VendorAvailability[]> => {
const response = await apiGet<{ data?: VendorAvailability[]; Data?: VendorAvailability[] }>(
`${base}/availability`,
{ searchParams: searchParams(params) },
);
return response.data ?? response.Data ?? [];
},
insights: async (params: {
from?: string;
to?: string;
locationId?: string | number;
trade?: string;
vendorId?: string | number;
}): Promise<VendorInsight[]> => {
const response = await apiGet<{ data?: VendorInsight[]; Data?: VendorInsight[] }>(
`${base}/insights`,
{ searchParams: searchParams(params) },
);
return response.data ?? response.Data ?? [];
},
downloadInsights: (format: "csv" | "pdf") =>
saveDownload(`insights.${format}`, `vendor-insights.${format}`),
sitePreferences: (locationId: string | number) =>
apiGet<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`),
replaceSitePreferences: (
locationId: string | number,
preferences: Array<{ vendorId: number; trade?: string; sortOrder: number }>,
) => apiPut<SiteVendorPreference[]>(`${base}/sites/${locationId}/preferred-vendors`, preferences),
assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) =>
apiPut<void>(`${base}/work-orders/${workOrderId}/assignment`, payload),
};

View file

@ -0,0 +1,69 @@
export interface VendorNotificationAction {
label: string;
href: string;
}
export interface VendorNotification {
id: string;
type: "NoVendor" | "VendorConflict";
severity: "Medium";
group: "No Vendor" | "Vendor Conflict";
title: string;
workOrderId?: number;
workOrderNumber?: string;
triggeredAt: string;
scheduledAt?: string;
action?: VendorNotificationAction;
actions?: VendorNotificationAction[];
workOrders?: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorAvailability {
id: number;
companyName: string;
contactName?: string;
tradeSpecialties?: string;
availabilityStatus: "Available" | "Unavailable" | "Unknown";
availabilityUpdatedAt?: string;
isPreferred: boolean;
preferenceRank?: number;
conflicts: Array<{
workOrderId: number;
workOrderNumber?: string;
start: string;
end: string;
}>;
}
export interface VendorInsight {
vendorId: number;
vendor: string;
completedJobs: number;
cancellationRefusalRate: number;
onTimeArrivalRate: number;
onTimeCompletionRate: number;
averageCycleHours?: number;
}
export interface SiteVendorPreference {
id: number;
locationId: number;
vendorId: number;
vendorName: string;
trade?: string;
sortOrder: number;
isStale: boolean;
}
export interface VendorAssignmentPayload {
vendorId: number;
scheduledStart?: string;
scheduledEnd?: string;
estimatedArrivalAt?: string;
etaManualOverride: boolean;
}

View file

@ -5,6 +5,7 @@ import type {
VendorPortalComment,
VendorPortalDispatchDetail,
VendorPortalDispatchSummary,
VendorPortalDocument,
VendorPortalSession,
VendorPortalSignoff,
} from "@/domain/vendor-portal/types/vendor-portal";
@ -14,6 +15,58 @@ function resolveVendorPortalUrl(path: string): string {
return `${base}/${API_PATHS.vendorPortal.base}${path}`;
}
function uploadVendorDocument(
token: string,
dispatchId: string | number,
file: File,
replacesDocumentId: number | undefined,
onProgress: (percent: number) => void,
): Promise<VendorPortalDocument> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
request.setRequestHeader("X-Vendor-Token", token);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
request.addEventListener("load", () => {
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
if (request.status < 200 || request.status >= 300) {
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
return;
}
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
});
request.addEventListener("error", () => reject(new Error("Upload failed")));
const body = new FormData();
body.append("file", file);
if (replacesDocumentId !== undefined) {
body.append("replacesDocumentId", String(replacesDocumentId));
}
request.send(body);
});
}
async function downloadVendorDocument(
token: string,
dispatchId: string | number,
document: VendorPortalDocument,
): Promise<void> {
const response = await fetch(
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
{ headers: { "X-Vendor-Token": token } },
);
if (!response.ok)
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = document.originalFileName;
anchor.click();
URL.revokeObjectURL(url);
}
async function vendorPortalCall<T>(
token: string,
path: string,
@ -131,4 +184,7 @@ export const vendorPortalApi = {
`${API_PATHS.vendorPortal.dispatches}/${id}/uplift-request/${requestId}/cancel`,
{ method: "POST" },
),
uploadDocument: uploadVendorDocument,
downloadDocument: downloadVendorDocument,
};

View file

@ -0,0 +1,12 @@
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
export function validateVendorDocument(file: File): string | undefined {
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
return "Only PDF, JPG, and PNG files are allowed.";
}
if (file.size > MAX_DOCUMENT_BYTES) {
return "Files must be 10 MB or smaller.";
}
}

View file

@ -14,11 +14,30 @@ export interface VendorPortalDispatchSummary {
scheduledDate?: string;
status: string;
poNumber?: string;
invoiceNumber?: string;
invoiceStatus?: string;
paymentStatus?: string;
nteAmount?: number;
dispatchedAt?: string;
completedDate?: string;
}
export interface VendorPortalDocument {
id: number;
originalFileName: string;
contentType: string;
sizeBytes: number;
scanStatus: "Pending" | "Passed" | "Rejected";
reviewStatus: "Processing" | "Approved" | "Rejected";
rejectionReason?: string;
version: number;
replacesDocumentId?: number;
createdDate?: string;
scannedAt?: string;
reviewedAt?: string;
canDownload: boolean;
}
export interface VendorPortalChecklistItem {
id: number;
itemText: string;
@ -77,4 +96,5 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
comments: VendorPortalComment[];
upliftRequests: VendorPortalUpliftRequest[];
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
}

View file

@ -258,6 +258,8 @@ export function mapWorkOrderDispatchSummary(raw: unknown): WorkOrderDispatchSumm
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
status: readString(item, "status", "Status"),
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
};
}
@ -505,6 +507,8 @@ export function mapDispatchDetail(raw: unknown): DispatchDetail {
status: readString(item, "status", "Status"),
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
completedDate: readString(item, "completedDate", "CompletedDate"),
description: readString(item, "description", "Description"),
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
@ -528,6 +532,8 @@ export function mapDispatchToVendorPayload(
VendorIds: payload.vendorIds,
NTEAmount: payload.nteAmount,
ScheduledDate: payload.scheduledDate,
EstimatedArrivalAt: payload.estimatedArrivalAt,
EtaManualOverride: payload.etaManualOverride ?? false,
Description: payload.description,
TaskListTemplateId: payload.taskListTemplateId,
CustomChecklistItems: payload.customChecklistItems,
@ -540,6 +546,8 @@ export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record
Status: payload.status,
NTEAmount: payload.nteAmount,
ScheduledDate: payload.scheduledDate,
EstimatedArrivalAt: payload.estimatedArrivalAt,
EtaManualOverride: payload.etaManualOverride ?? false,
CompletedDate: payload.completedDate,
Description: payload.description,
};

View file

@ -8,6 +8,8 @@ export interface WorkOrderDispatchSummary {
nteAmount: number | null;
status: string;
dispatchedAt: string;
estimatedArrivalAt: string;
etaManualOverride: boolean;
}
export interface WorkOrderVendorUpdatePayload {
@ -59,6 +61,8 @@ export interface DispatchDetail {
status: string;
nteAmount: number | null;
scheduledDate: string;
estimatedArrivalAt: string;
etaManualOverride: boolean;
completedDate: string;
description: string;
dispatchedAt: string;
@ -76,6 +80,8 @@ export interface DispatchToVendorPayload {
vendorIds: (string | number)[];
nteAmount: number | null;
scheduledDate: string | null;
estimatedArrivalAt?: string | null;
etaManualOverride?: boolean;
description: string;
taskListTemplateId: number | null;
customChecklistItems: string[] | null;
@ -86,6 +92,8 @@ export interface UpdateDispatchPayload {
status: string;
nteAmount: number | null;
scheduledDate: string | null;
estimatedArrivalAt?: string | null;
etaManualOverride?: boolean;
completedDate: string | null;
description: string;
}

View file

@ -0,0 +1,69 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
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 { 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";
type SaveWorkOrderInput = {
workOrderId?: string | number;
values: WorkOrderFormSchemaValues;
vendorAssignment?: VendorAssignmentPayload;
};
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}`);
}
}
export function useSaveWorkOrder() {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ workOrderId, values, vendorAssignment }: SaveWorkOrderInput) => {
const savedWorkOrder = workOrderId
? await workOrdersApi.update(workOrderId, values)
: await workOrdersApi.create(values);
if (vendorAssignment) {
try {
await vendorOperationsApi.assignVendor(savedWorkOrder.id, vendorAssignment);
} catch (error) {
throw new PartialWorkOrderSaveError(savedWorkOrder.id, error);
}
}
return savedWorkOrder;
},
onSuccess: (savedWorkOrder) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(savedWorkOrder.id),
});
toast.success("Work order saved successfully");
navigate(`/workorders/${savedWorkOrder.id}`);
},
onError: (error) => {
if (error instanceof PartialWorkOrderSaveError) {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(error.workOrderId),
});
toast.error(error.message);
navigate(`/workorders/${error.workOrderId}`);
return;
}
toast.error(error.message || "Failed to save work order");
},
});
}

View file

@ -0,0 +1,102 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
import { renderWithProviders } from "@/test/test-utils";
const quarantinedDocument: VendorPortalDocument = {
id: 12,
originalFileName: "completion.pdf",
contentType: "application/pdf",
sizeBytes: 1024,
scanStatus: "Pending",
reviewStatus: "Processing",
version: 1,
canDownload: false,
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("VendorPortalDocuments", () => {
it("rejects unsupported files before starting an upload", async () => {
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [new File(["text"], "notes.txt", { type: "text/plain" })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, and PNG files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
it("keeps pending documents quarantined", () => {
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[quarantinedDocument]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "Quarantined" })).toBeDisabled();
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByText("Processing")).toBeInTheDocument();
});
it("reports upload progress and refreshes the dispatch after success", async () => {
const onChanged = vi.fn().mockResolvedValue(undefined);
let finishUpload: (() => void) | undefined;
vi.spyOn(vendorPortalApi, "uploadDocument").mockImplementation(
async (_token, _dispatchId, _file, _replacementId, onProgress) => {
onProgress?.(65);
await new Promise<void>((resolve) => {
finishUpload = resolve;
});
return quarantinedDocument;
},
);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={onChanged}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: {
files: [
new File(["%PDF-1.4"], "completion.pdf", {
type: "application/pdf",
}),
],
},
});
expect(await screen.findByText("Uploading… 65%")).toBeInTheDocument();
finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
});
});