diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts new file mode 100644 index 00000000..efde75b5 --- /dev/null +++ b/e2e/vendors/vendor-operations.spec.ts @@ -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 }, + ]); + }); +}); diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx new file mode 100644 index 00000000..29c18cf6 --- /dev/null +++ b/src/app/(protected)/notifications/index.tsx @@ -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()); + + return ( + + + + Items requiring attention + + + Medium-severity vendor reminders refresh automatically. They do not create banners or + toasts. + + + {isLoading && } + {error && {error.message}} + {!isLoading && !error && data.length === 0 && ( + + No vendor alerts require attention. + + )} + {[...groups.entries()].map(([group, items]) => ( + + + {group} + + + + {items.map((item) => ( + + + + + + {item.title} + + + {item.workOrderNumber ?? + item.workOrders?.map((workOrder) => workOrder.workOrderNumber).join(" ↔ ")} + + {item.scheduledAt && ( + + Scheduled {new Date(item.scheduledAt).toLocaleString()} + + )} + + + {item.action && ( + + )} + {item.actions?.map((action) => ( + + ))} + + + + ))} + + + ))} + + ); +} diff --git a/src/app/(protected)/vendors/insights.tsx b/src/app/(protected)/vendors/insights.tsx new file mode 100644 index 00000000..0d78a7d7 --- /dev/null +++ b/src/app/(protected)/vendors/insights.tsx @@ -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 ( + + + + Vendor performance insights + + + Metrics include inactive vendors for historical reporting and use the same filtered + dispatch population. + + + + + setFrom(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTo(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setTrade(event.target.value)} + /> + + + + + {isLoading && } + {error && {error.message}} + {!isLoading && !error && data.length === 0 && ( + No data matches the selected filters. + )} + {data.length > 0 && ( + + + + + Vendor + Completed + Cancellation / refusal + On-time arrival + On-time completion + Avg. cycle + + + + {data.map((row) => ( + + {row.vendor} + {row.completedJobs} + {percent(row.cancellationRefusalRate)} + {percent(row.onTimeArrivalRate)} + {percent(row.onTimeCompletionRate)} + + {row.averageCycleHours == null + ? "Insufficient data" + : `${Number(row.averageCycleHours).toFixed(1)}h`} + + + ))} + +
+
+ )} +
+ ); +} diff --git a/src/app/(protected)/vendors/preferences.tsx b/src/app/(protected)/vendors/preferences.tsx new file mode 100644 index 00000000..b6cba569 --- /dev/null +++ b/src/app/(protected)/vendors/preferences.tsx @@ -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(""); + const [draft, setDraft] = useState([]); + const [trade, setTrade] = useState(""); + const [vendorId, setVendorId] = useState(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 ( + + + + Preferred vendors by site + + + Preferred vendors rank first, but availability, conflict, trade, active-state, and + permission checks still apply. + + + + option.name} + value={locations.find((location) => location.id === locationId) ?? null} + onChange={(_event, option) => setLocationId(option?.id ?? "")} + renderInput={(params) => } + /> + + {error && {error.message}} + {locationId !== "" && ( + <> + + + setTrade(event.target.value)} + /> + + `${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) => } + className="flex-1" + /> + + + + + {draft.map((item, index) => ( + + + + {index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"} + + {item.isStale && Stale preference} + move(index, -1)} + disabled={index === 0} + > + + + move(index, 1)} + disabled={index === draft.length - 1} + > + + + setDraft((current) => current.filter((_, row) => row !== index))} + > + + + + + ))} + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx index 69b1bedd..11520328 100644 --- a/src/app/(protected)/workorders/_components/work-order-form.tsx +++ b/src/app/(protected)/workorders/_components/work-order-form.tsx @@ -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(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"} - + void onSubmit(values))} + className="flex flex-col gap-8" + > Work Order Details @@ -195,6 +235,79 @@ export default function WorkOrderFormPage() { + + Vendor & Time + + + `${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}` + } + value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null} + onChange={(_event, option) => setSelectedVendorId(option?.id ?? null)} + renderOption={(props, option) => ( +
  • + + + + {option.companyName} + {option.contactName ? ` · ${option.contactName}` : ""} + + {option.isPreferred && ( + + )} + + + {option.conflicts.length > 0 && ( + + Conflicts with{" "} + {option.conflicts.map((conflict) => conflict.workOrderNumber).join(", ")} + + )} + +
  • + )} + renderInput={(params) => } + className="flex-1" + /> + setEstimatedArrivalAt(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + setEtaManualOverride(event.target.checked)} + /> + } + label="Manual ETA override" + /> +
    + {selectedVendorId != null && + availableVendors.find((vendor) => vendor.id === selectedVendorId) + ?.availabilityStatus === "Unknown" && ( + + Availability is unknown. This selection is not treated as confirmed availability. + + )} +
    + Classification diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index 2edae8a1..ac8d97b6 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -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() { ${Number(data.nteAmount).toFixed(2)} )} +
    + Invoice + + {data.invoiceStatus || "Not Submitted"} + {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} + +
    +
    + Payment + {data.paymentStatus || "Unavailable"} +
    {data.scheduledDate && (
    Scheduled @@ -587,6 +599,17 @@ export default function VendorPortalDispatchPage() { />
    +
    +

    Completion documents

    + +
    +

    NTE Uplift Requests

    Promise; +}; + +type UploadRequest = { + file: File; + replacementId?: number; +}; + +export function VendorPortalDocuments({ + token, + dispatchId, + documents, + locked, + onChanged, +}: VendorPortalDocumentsProps) { + const inputRef = useRef(null); + const [replacementId, setReplacementId] = useState(); + const [progress, setProgress] = useState(); + const [validationError, setValidationError] = useState(); + + 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 ( + <> + uploadSelectedFile(event.target.files?.[0])} + /> + + {documents.length === 0 && ( + No completion documents uploaded. + )} + + {documents.map((document) => ( +
    +
    + File + + {document.originalFileName} · v{document.version} + +
    +
    + Security scan + {document.scanStatus} +
    +
    + Review + {document.reviewStatus} +
    + {document.rejectionReason && ( + {document.rejectionReason} + )} +
    + + {!locked && ( + + )} +
    +
    + ))} + + {progress !== undefined && ( +
    + + Uploading… {progress}% +
    + )} + + {uploadError && ( + + {uploadError} + + )} + + {!locked && ( +
    + +
    + )} + + ); +} diff --git a/src/app/v/_components/vendor-portal-feedback.tsx b/src/app/v/_components/vendor-portal-feedback.tsx new file mode 100644 index 00000000..891480e0 --- /dev/null +++ b/src/app/v/_components/vendor-portal-feedback.tsx @@ -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 ( +

    + {children} +

    + ); +} diff --git a/src/config/menu.ts b/src/config/menu.ts index f39c9879..03716882 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -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" }, ], }, { diff --git a/src/domain/vendor-operations/api/vendor-operations-api.ts b/src/domain/vendor-operations/api/vendor-operations-api.ts new file mode 100644 index 00000000..67464aca --- /dev/null +++ b/src/domain/vendor-operations/api/vendor-operations-api.ts @@ -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) { + return Object.fromEntries( + Object.entries(values).filter( + (entry): entry is [string, string | number] => entry[1] !== undefined, + ), + ); +} + +async function saveDownload(path: string, fileName: string): Promise { + 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 => { + 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 => { + 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 => { + 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(`${base}/sites/${locationId}/preferred-vendors`), + + replaceSitePreferences: ( + locationId: string | number, + preferences: Array<{ vendorId: number; trade?: string; sortOrder: number }>, + ) => apiPut(`${base}/sites/${locationId}/preferred-vendors`, preferences), + + assignVendor: (workOrderId: string | number, payload: VendorAssignmentPayload) => + apiPut(`${base}/work-orders/${workOrderId}/assignment`, payload), +}; diff --git a/src/domain/vendor-operations/types/vendor-operations.ts b/src/domain/vendor-operations/types/vendor-operations.ts new file mode 100644 index 00000000..b0348298 --- /dev/null +++ b/src/domain/vendor-operations/types/vendor-operations.ts @@ -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; +} diff --git a/src/domain/vendor-portal/api/vendor-portal-api.ts b/src/domain/vendor-portal/api/vendor-portal-api.ts index 2630e86a..9dc645bf 100644 --- a/src/domain/vendor-portal/api/vendor-portal-api.ts +++ b/src/domain/vendor-portal/api/vendor-portal-api.ts @@ -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 { + 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; + 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 { + 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( 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, }; diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts new file mode 100644 index 00000000..3c1f5203 --- /dev/null +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -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."; + } +} diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts index 349931b0..836b2d48 100644 --- a/src/domain/vendor-portal/types/vendor-portal.ts +++ b/src/domain/vendor-portal/types/vendor-portal.ts @@ -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[]; } diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index c424c26e..b52a1fd8 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -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, }; diff --git a/src/domain/work-orders/types/dispatch.ts b/src/domain/work-orders/types/dispatch.ts index fdb0175c..f93bd94d 100644 --- a/src/domain/work-orders/types/dispatch.ts +++ b/src/domain/work-orders/types/dispatch.ts @@ -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; } diff --git a/src/domain/work-orders/use-cases/use-save-work-order.ts b/src/domain/work-orders/use-cases/use-save-work-order.ts new file mode 100644 index 00000000..fb7ab237 --- /dev/null +++ b/src/domain/work-orders/use-cases/use-save-work-order.ts @@ -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"); + }, + }); +} diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx new file mode 100644 index 00000000..428090b6 --- /dev/null +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -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( + , + { 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( + , + { 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((resolve) => { + finishUpload = resolve; + }); + return quarantinedDocument; + }, + ); + renderWithProviders( + , + { 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()); + }); +});