mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(vendors): complete operations roadmap frontend
This commit is contained in:
parent
922d6c9a57
commit
ac4ecac9f0
18 changed files with 1354 additions and 15 deletions
190
e2e/vendors/vendor-operations.spec.ts
vendored
Normal file
190
e2e/vendors/vendor-operations.spec.ts
vendored
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
104
src/app/(protected)/notifications/index.tsx
Normal file
104
src/app/(protected)/notifications/index.tsx
Normal 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
123
src/app/(protected)/vendors/insights.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/app/(protected)/vendors/preferences.tsx
vendored
Normal file
177
src/app/(protected)/vendors/preferences.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
158
src/app/v/_components/vendor-portal-documents.tsx
Normal file
158
src/app/v/_components/vendor-portal-documents.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
src/app/v/_components/vendor-portal-feedback.tsx
Normal file
23
src/app/v/_components/vendor-portal-feedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
78
src/domain/vendor-operations/api/vendor-operations-api.ts
Normal file
78
src/domain/vendor-operations/api/vendor-operations-api.ts
Normal 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),
|
||||
};
|
||||
69
src/domain/vendor-operations/types/vendor-operations.ts
Normal file
69
src/domain/vendor-operations/types/vendor-operations.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
12
src/domain/vendor-portal/lib/document-validation.ts
Normal file
12
src/domain/vendor-portal/lib/document-validation.ts
Normal 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.";
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
69
src/domain/work-orders/use-cases/use-save-work-order.ts
Normal file
69
src/domain/work-orders/use-cases/use-save-work-order.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
102
src/test/app/v/vendor-portal-documents.test.tsx
Normal file
102
src/test/app/v/vendor-portal-documents.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue