diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts new file mode 100644 index 00000000..97d23975 --- /dev/null +++ b/e2e/vendors/vendor-operations.spec.ts @@ -0,0 +1,508 @@ +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/edit/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/edit/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()); + const { pathname } = new URL(route.request().url()); + if (pathname.endsWith(".csv")) { + await route.fulfill({ status: 200, contentType: "text/csv", body: "Vendor,Completed\n" }); + return; + } + if (pathname.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("From", { exact: true }).fill("2026-01-01"); + await page.getByLabel("To", { exact: true }).fill("2026-01-31"); + await page.getByLabel("Trade", { exact: true }).fill("Plumbing"); + + const insightsQueryHas = (name: string, value: string) => + requestedUrls.some( + (url) => + new URL(url).pathname.endsWith("/insights") && + new URL(url).searchParams.get(name) === value, + ); + await expect.poll(() => insightsQueryHas("from", "2026-01-01")).toBe(true); + await expect.poll(() => insightsQueryHas("to", "2026-01-31")).toBe(true); + await expect.poll(() => insightsQueryHas("trade", "Plumbing")).toBe(true); + + await page.getByRole("button", { name: "Export CSV" }).click(); + await page.getByRole("button", { name: "Export PDF" }).click(); + + const exportRequest = (format: "csv" | "pdf") => + requestedUrls.find((url) => new URL(url).pathname.endsWith(`/insights.${format}`)); + const exportHasFilters = (url: string | undefined) => { + if (!url) return false; + const params = new URL(url).searchParams; + return ( + params.get("from") === "2026-01-01" && + params.get("to") === "2026-01-31" && + params.get("trade") === "Plumbing" + ); + }; + + await expect.poll(() => exportHasFilters(exportRequest("csv"))).toBe(true); + await expect.poll(() => exportHasFilters(exportRequest("pdf"))).toBe(true); + }); + + test("reorders and saves preferred vendors for a site", async ({ page }) => { + 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 }, + ]); + }); + + test("creates a work order and assigns the selected technician with ETA", async ({ page }) => { + await authenticate(page); + let assignment: unknown; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/AddWorkorder", (route) => + fulfillJson(route, { + data: { + Id: 900, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + await page.route("**/api/vendor-operations/work-orders/900/assignment", async (route) => { + assignment = route.request().postDataJSON(); + await fulfillJson(route, { + availabilityStatus: "Available", + conflicts: [ + { + workOrderId: 502, + workOrderNumber: "WO-502", + start: "2026-07-25T12:00:00Z", + end: "2026-07-25T14:00:00Z", + }, + ], + }); + }); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 900, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/new"); + await page.getByLabel("Title").fill("Boiler repair"); + await page.getByLabel("Location").click(); + await page.getByRole("option", { name: "St. Louis HQ" }).click(); + await page.getByLabel("Priority").click(); + await page.getByRole("option", { name: "Sev 1" }).click(); + await page.getByLabel("Scheduled").fill("2026-07-25"); + await page.getByLabel("Appointment start").fill("2026-07-25T10:00"); + await page.getByLabel("Appointment end").fill("2026-07-25T12:00"); + await page.getByLabel("Company + Technician").click(); + await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click(); + await page.getByLabel("Assignment ETA").fill("2026-07-25T08:30"); + await page.getByLabel("Manual ETA override").check(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect + .poll(() => assignment) + .toEqual({ + vendorId: 7, + scheduledStart: "2026-07-25T10:00:00.000Z", + scheduledEnd: "2026-07-25T12:00:00.000Z", + estimatedArrivalAt: "2026-07-25T08:30:00.000Z", + etaManualOverride: true, + }); + await expect(page.getByText(/scheduling conflict/i)).toBeVisible(); + await expect(page).toHaveURL(/\/workorders\/900$/); + }); + + test("preserves an existing ETA when editing in a non-UTC timezone", async ({ browser }) => { + const context = await browser.newContext({ timezoneId: "America/New_York" }); + const page = await context.newPage(); + await authenticate(page); + let assignment: Record | undefined; + + const workOrder = { + Id: 901, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + CreatedDate: "2026-07-20", + ScheduledDate: "2026-07-25", + Comments: [], + AuditLog: [], + Dispatches: [ + { + Id: 44, + VendorId: 7, + Status: "Acknowledged", + EstimatedArrivalAt: "2026-07-25T12:30:00.000Z", + EtaManualOverride: true, + }, + ], + }; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { data: workOrder }), + ); + await page.route("**/api/WorkOrder/EditWorkorder", (route) => + fulfillJson(route, { data: workOrder }), + ); + await page.route("**/api/vendor-operations/work-orders/901/assignment", async (route) => { + assignment = route.request().postDataJSON() as Record; + await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] }); + }); + + await page.goto("/workorders/edit/901"); + await expect(page.getByLabel("Assignment ETA")).toHaveValue("2026-07-25T08:30"); + await page.getByRole("button", { name: "Update" }).click(); + + await expect.poll(() => assignment?.estimatedArrivalAt).toBe("2026-07-25T12:30:00.000Z"); + await context.close(); + }); + + test("converts explicit local schedule bounds to the correct UTC instants across a DST transition", async ({ + browser, + }) => { + // America/New_York springs forward on 2026-03-08 at 02:00 EST -> 03:00 EDT. + // The window below straddles the transition: 01:00 is EST (UTC-5) while + // 04:00 is EDT (UTC-4). Each bound must resolve its own offset, proving no + // fixed-window assumption remains. + const context = await browser.newContext({ timezoneId: "America/New_York" }); + const page = await context.newPage(); + await authenticate(page); + let assignment: Record | undefined; + + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/AddWorkorder", (route) => + fulfillJson(route, { + data: { + Id: 902, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + await page.route("**/api/vendor-operations/work-orders/902/assignment", async (route) => { + assignment = route.request().postDataJSON() as Record; + await fulfillJson(route, { availabilityStatus: "Available", conflicts: [] }); + }); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 902, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/new"); + await page.getByLabel("Title").fill("Boiler repair"); + await page.getByLabel("Location").click(); + await page.getByRole("option", { name: "St. Louis HQ" }).click(); + await page.getByLabel("Priority").click(); + await page.getByRole("option", { name: "Sev 1" }).click(); + await page.getByLabel("Appointment start").fill("2026-03-08T01:00"); + await page.getByLabel("Appointment end").fill("2026-03-08T04:00"); + await page.getByLabel("Company + Technician").click(); + await page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }).click(); + await page.getByRole("button", { name: "Create" }).click(); + + await expect.poll(() => assignment?.scheduledStart).toBe("2026-03-08T06:00:00.000Z"); + await expect.poll(() => assignment?.scheduledEnd).toBe("2026-03-08T08:00:00.000Z"); + await context.close(); + }); + + test("focuses the Vendor & Time section when arriving from a no-vendor alert", async ({ + page, + }) => { + await authenticate(page); + await page.route("**/api/Contact/GetLocations", (route) => + fulfillJson(route, { data: [{ Id: 10, Name: "St. Louis HQ" }] }), + ); + await page.route("**/api/Employee/Dropdown", (route) => fulfillJson(route, { data: [] })); + await page.route("**/api/DropdownOptions/ByCategory/**", (route) => + fulfillJson(route, { data: [] }), + ); + await page.route("**/api/vendor-operations/availability*", (route) => + fulfillJson(route, { + data: [ + { + id: 7, + companyName: "Gateway Plumbing", + contactName: "Taylor Tech", + availabilityStatus: "Available", + isPreferred: true, + conflicts: [], + }, + ], + }), + ); + await page.route("**/api/WorkOrder/GetWorkorderById*", (route) => + fulfillJson(route, { + data: { + Id: 501, + WorkerOrderTitle: "Boiler repair", + LocationId: 10, + Priority: "Sev 1", + Status: "Open", + Comments: [], + AuditLog: [], + Dispatches: [], + }, + }), + ); + + await page.goto("/workorders/edit/501?assignVendor=1"); + + const vendorSection = page.getByRole("region", { name: "Vendor & Time" }); + await expect(vendorSection).toBeVisible(); + await expect(vendorSection).toBeFocused(); + await page.getByLabel("Company + Technician").click(); + const option = page.getByRole("option", { name: /Gateway Plumbing · Taylor Tech/ }); + await expect(option).toContainText("Unknown"); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 5a79ae6d..7360b05c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ use: { // Port 4173 isolates Playwright from the app's normal port 3000 server. baseURL: "http://localhost:4173", + // Pin the browser timezone to UTC so date-to-ISO assertions in the default + // create flow are deterministic regardless of host locale. DST-transition + // scenarios override this with their own non-UTC context. + timezoneId: "UTC", trace: "on-first-retry", }, projects: [ diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json index 197c24da..d82858fd 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -13,11 +13,6 @@ "maxLines": 782, "reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields." }, - { - "path": "src/app/v/[token]/dispatch/[id].tsx", - "maxLines": 651, - "reason": "Vendor-portal dispatch detail route; legacy page pending decomposition." - }, { "path": "src/app/(protected)/vendors/index.tsx", "maxLines": 612, diff --git a/src/app/(protected)/notifications/index.tsx b/src/app/(protected)/notifications/index.tsx new file mode 100644 index 00000000..c4e0b75e --- /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 && } + {Boolean(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 != null && ( + + Scheduled {new Date(item.scheduledAt).toLocaleString()} + + )} + + + {item.action != null && ( + + )} + {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..de5c5f0c --- /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 && } + {Boolean(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..fdb93618 --- /dev/null +++ b/src/app/(protected)/vendors/preferences.tsx @@ -0,0 +1,254 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Autocomplete, + Box, + Button, + IconButton, + Paper, + Stack, + TextField, +} from "@mui/material"; +import { ArrowDown, ArrowUp, Trash2 } from "lucide-react"; +import { toast } from "react-toastify"; +import { Text } from "@/components/ui/text"; +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"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; + +const EMPTY_PREFERENCES: SiteVendorPreference[] = []; + +type AddVendorPreferenceControlsProps = { + trade: string; + onTradeChange: (value: string) => void; + vendorOptions: VendorDropdownItem[]; + vendors: VendorDropdownItem[]; + vendorId: number | null; + onVendorChange: (vendorId: number | null) => void; + onAdd: () => void; +}; + +function AddVendorPreferenceControls({ + trade, + onTradeChange, + vendorOptions, + vendors, + vendorId, + onVendorChange, + onAdd, +}: AddVendorPreferenceControlsProps) { + return ( + + + onTradeChange(event.target.value)} + /> + + `${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}` + } + value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null} + onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)} + renderInput={(params) => } + className="flex-1" + /> + + + + ); +} + +type PreferenceRowProps = { + item: SiteVendorPreference; + index: number; + total: number; + onMove: (index: number, offset: number) => void; + onRemove: (index: number) => void; +}; + +function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) { + return ( + + + + {index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"} + + {item.isStale && ( + + Stale preference + + )} + onMove(index, -1)} + disabled={index === 0} + > + + + onMove(index, 1)} + disabled={index === total - 1} + > + + + onRemove(index)}> + + + + + ); +} + +export default function VendorPreferencesPage() { + const queryClient = useQueryClient(); + const [locationId, setLocationId] = useState(""); + const [draft, setDraft] = useState([]); + const [isDirty, setIsDirty] = useState(false); + 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: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({ + queryKey, + queryFn: () => vendorOperationsApi.sitePreferences(locationId), + enabled: locationId !== "", + }); + + // A background refetch must not clobber in-flight edits, so only adopt server + // data while the draft is clean (initial load, site change, or after a save). + useEffect(() => { + if (!isDirty) { + setDraft(serverPreferences); + } + }, [isDirty, serverPreferences]); + + const vendorOptions = useMemo(() => { + const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId)); + return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id))); + }, [draft, vendors]); + + const save = useMutation({ + mutationFn: () => + vendorOperationsApi.replaceSitePreferences( + locationId, + draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })), + ), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey }); + }, + onSuccess: (savedPreferences) => { + queryClient.setQueryData(queryKey, savedPreferences); + setDraft(savedPreferences); + setIsDirty(false); + toast.success("Preferred vendors updated"); + }, + onError: () => toast.error("Unable to save preferred vendors. Please try again."), + }); + + 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 })); + }); + setIsDirty(true); + }; + + 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); + setIsDirty(true); + }; + + const remove = (index: number) => { + setDraft((current) => current.filter((_, row) => row !== index)); + setIsDirty(true); + }; + + const selectLocation = (nextLocationId: string | number) => { + setLocationId(nextLocationId); + setDraft(EMPTY_PREFERENCES); + setIsDirty(false); + }; + + return ( + + + + 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) => selectLocation(option?.id ?? "")} + renderInput={(params) => } + /> + + + Unable to load preferred vendors. Please try again. + + {locationId !== "" && ( + <> + + + {draft.map((item, index) => ( + + ))} + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx b/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx new file mode 100644 index 00000000..92e55413 --- /dev/null +++ b/src/app/(protected)/workorders/_components/vendor-assignment-section.tsx @@ -0,0 +1,153 @@ +import { + Alert, + Autocomplete, + Box, + Chip, + FormControlLabel, + Stack, + Switch, + TextField, +} from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { VendorAvailability } from "@/domain/vendor-operations/types/vendor-operations"; + +function SectionTitle({ children }: { children: string }) { + return ( + + {children} + + ); +} + +type VendorAndTimeSectionProps = { + containerRef: React.RefObject; + availableVendors: VendorAvailability[]; + isLoadingVendors: boolean; + selectedVendorId: number | null; + onSelectVendor: (id: number | null) => void; + estimatedArrivalAt: string; + onEtaChange: (value: string) => void; + etaManualOverride: boolean; + onOverrideChange: (value: boolean) => void; + scheduledStartLocal: string; + onScheduledStartChange: (value: string) => void; + scheduledEndLocal: string; + onScheduledEndChange: (value: string) => void; + scheduleBoundsError: string | null; +}; + +export function VendorAndTimeSection({ + containerRef, + availableVendors, + isLoadingVendors, + selectedVendorId, + onSelectVendor, + estimatedArrivalAt, + onEtaChange, + etaManualOverride, + onOverrideChange, + scheduledStartLocal, + onScheduledStartChange, + scheduledEndLocal, + onScheduledEndChange, + scheduleBoundsError, +}: VendorAndTimeSectionProps) { + const hasBoundsError = Boolean(scheduleBoundsError); + return ( + + Vendor & Time + + onScheduledStartChange(event.target.value)} + error={hasBoundsError} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + onScheduledEndChange(event.target.value)} + error={hasBoundsError} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + + + {scheduleBoundsError} + + + + `${option.companyName}${option.contactName ? ` · ${option.contactName}` : ""}` + } + value={availableVendors.find((vendor) => vendor.id === selectedVendorId) ?? null} + onChange={(_event, option) => onSelectVendor(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" + /> + onEtaChange(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + className="flex-1" + /> + onOverrideChange(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. + + + )} +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-fields.tsx b/src/app/(protected)/workorders/_components/work-order-fields.tsx new file mode 100644 index 00000000..501d69c2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/work-order-fields.tsx @@ -0,0 +1,319 @@ +import { Autocomplete, Box, MenuItem, Stack, TextField } from "@mui/material"; +import { Controller } from "react-hook-form"; +import type { Control, FieldErrors, UseFormRegister } from "react-hook-form"; +import { Text } from "@/components/ui/text"; +import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option"; +import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema"; +import { + WORK_ORDER_PRIORITY_OPTIONS, + WORK_ORDER_STATUS_OPTIONS, + type LocationOption, +} from "@/domain/work-orders/types/work-order"; +import type { EmployeeOption } from "@/domain/work-orders/types/work-order-common"; + +function SectionTitle({ children }: { children: string }) { + return ( + + {children} + + ); +} + +type FormFieldHandlers = { + register: UseFormRegister; + control: Control; + errors: FieldErrors; +}; + +type WorkOrderDetailsFieldsProps = FormFieldHandlers & { + locationOptions: LocationOption[]; + isLoadingLocations: boolean; +}; + +export function WorkOrderDetailsFields({ + register, + control, + errors, + locationOptions, + isLoadingLocations, +}: WorkOrderDetailsFieldsProps) { + return ( + + Work Order Details + + + + + + ( + option.name} + value={ + locationOptions.find((location) => String(location.id) === field.value) ?? null + } + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + ); +} + +type ClassificationFieldsProps = { + control: Control; + problems: DropdownOption[]; + trades: DropdownOption[]; + subTrades: DropdownOption[]; + tradeValue: string; +}; + +export function ClassificationFields({ + control, + problems, + trades, + subTrades, + tradeValue, +}: ClassificationFieldsProps) { + return ( + + Classification + + ( + + Select Problem + {problems.map((option) => ( + + {option.value} + + ))} + + )} + /> + ( + + Select Trade + {trades.map((option) => ( + + {option.value} + + ))} + + )} + /> + ( + + + {tradeValue ? "Select Sub-Trade" : "Select a Trade first"} + + {subTrades.map((option) => ( + + {option.value} + + ))} + + )} + /> + + + ); +} + +type ScheduleFieldsProps = FormFieldHandlers & { + employees: EmployeeOption[]; +}; + +export function ScheduleFields({ register, control, errors, employees }: ScheduleFieldsProps) { + return ( + + Schedule & Priority + + ( + + Select Priority + {WORK_ORDER_PRIORITY_OPTIONS.map((priority) => ( + + {priority} + + ))} + + )} + /> + ( + + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + + {status} + + ))} + + )} + /> + ( + employee.name)} + value={field.value} + onChange={(_event, value) => field.onChange(value ?? "")} + onInputChange={(_event, value) => field.onChange(value)} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + + + + + ); +} + +export function FinancialFields({ + register, +}: { + register: UseFormRegister; +}) { + return ( + + Financial + + + + + + + ); +} + +export function DescriptionFields({ + register, +}: { + register: UseFormRegister; +}) { + return ( + + Description + + + ); +} diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx index 69b1bedd..35e3afa7 100644 --- a/src/app/(protected)/workorders/_components/work-order-form.tsx +++ b/src/app/(protected)/workorders/_components/work-order-form.tsx @@ -1,62 +1,141 @@ -import { useEffect, useMemo } from "react"; -import { Controller, useForm, useWatch } from "react-hook-form"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Link as RouterLink, useNavigate, useParams } from "react-router"; -import { - Alert, - Autocomplete, - Box, - Breadcrumbs, - Button, - CircularProgress, - Link, - MenuItem, - Paper, - Stack, - TextField, - Typography, -} from "@mui/material"; +import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router"; +import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material"; +import { Text } from "@/components/ui/text"; +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 { workOrderFormSchema, type WorkOrderFormSchemaValues, } from "@/domain/work-orders/schemas/work-order-schema"; -import { - createDefaultWorkOrderFormValues, - WORK_ORDER_PRIORITY_OPTIONS, - WORK_ORDER_STATUS_OPTIONS, - type LocationOption, -} from "@/domain/work-orders/types/work-order"; +import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order"; import { useWorkOrderDetail, useWorkOrderEmployeesDropdown, useWorkOrderLocations, } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useSaveWorkOrder } from "@/domain/work-orders/use-cases/use-save-work-order"; +import { toDateTimeLocalInputValue } from "@/lib/time-utils"; import { - useCreateWorkOrder, - useUpdateWorkOrder, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; + ClassificationFields, + DescriptionFields, + FinancialFields, + ScheduleFields, + WorkOrderDetailsFields, +} from "@/app/(protected)/workorders/_components/work-order-fields"; +import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section"; -function SectionTitle({ children }: { children: string }) { - return ( - - {children} - - ); +type ScheduleBounds = { + kind: "valid" | "invalid" | "open"; + startIso: string | undefined; + endIso: string | undefined; + error: string | null; +}; + +function computeScheduleBounds(startLocal: string, endLocal: string): ScheduleBounds { + const hasStart = startLocal.trim() !== ""; + const hasEnd = endLocal.trim() !== ""; + + if (hasStart && hasEnd) { + const start = new Date(startLocal); + const end = new Date(endLocal); + if ( + !Number.isNaN(start.getTime()) && + !Number.isNaN(end.getTime()) && + end.getTime() > start.getTime() + ) { + return { + kind: "valid", + startIso: start.toISOString(), + endIso: end.toISOString(), + error: null, + }; + } + return { + kind: "invalid", + startIso: undefined, + endIso: undefined, + error: "Schedule end must be later than schedule start.", + }; + } + + if (hasStart || hasEnd) { + return { + kind: "invalid", + startIso: undefined, + endIso: undefined, + error: "Provide both a schedule start and end, or leave both blank.", + }; + } + + return { kind: "open", startIso: undefined, endIso: undefined, error: null }; } -export default function WorkOrderFormPage() { +function useVendorSchedule( + scheduledStartLocal: string, + scheduledEndLocal: string, + locationValue: string, + tradeValue: string, +) { + const scheduleBounds = useMemo( + () => computeScheduleBounds(scheduledStartLocal, scheduledEndLocal), + [scheduledStartLocal, scheduledEndLocal], + ); + + const { data: availableVendors = [], isLoading: isLoadingVendors } = useQuery({ + queryKey: [ + "vendor-operations", + "availability", + scheduleBounds.startIso ?? null, + scheduleBounds.endIso ?? null, + locationValue, + tradeValue, + ], + queryFn: () => + vendorOperationsApi.availability({ + start: scheduleBounds.startIso, + end: scheduleBounds.endIso, + locationId: locationValue || undefined, + trade: tradeValue || undefined, + }), + enabled: scheduleBounds.kind !== "invalid", + staleTime: 30_000, + }); + + const truthfulVendors = + scheduleBounds.kind === "valid" + ? availableVendors + : availableVendors.map((vendor) => ({ + ...vendor, + availabilityStatus: "Unknown" as const, + conflicts: [], + })); + + return { availableVendors: truthfulVendors, isLoadingVendors, scheduleBounds }; +} + +function useWorkOrderFormState() { const { id } = useParams(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const isEdit = Boolean(id); + const assignVendorRequested = searchParams.get("assignVendor") === "1"; const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id); 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 vendorSectionRef = useRef(null); + const [selectedVendorId, setSelectedVendorId] = useState(null); + const [estimatedArrivalAt, setEstimatedArrivalAt] = useState(""); + const [etaManualOverride, setEtaManualOverride] = useState(false); + const [scheduledStartLocal, setScheduledStartLocal] = useState(""); + const [scheduledEndLocal, setScheduledEndLocal] = useState(""); const { register, @@ -71,6 +150,14 @@ export default function WorkOrderFormPage() { }); const tradeValue = useWatch({ control, name: "trade" }); + const locationValue = useWatch({ control, name: "locationId" }); + + const { availableVendors, isLoadingVendors, scheduleBounds } = useVendorSchedule( + scheduledStartLocal, + scheduledEndLocal, + locationValue, + tradeValue, + ); const { data: subTrades = [] } = useDropdownOptionsByCategory("SubTrade", { parent: tradeValue || undefined, @@ -81,6 +168,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(toDateTimeLocalInputValue(dispatch?.estimatedArrivalAt)); + setEtaManualOverride(Boolean(dispatch?.etaManualOverride)); } }, [detail, isEdit, reset]); @@ -90,23 +181,120 @@ export default function WorkOrderFormPage() { } }, [tradeValue, setValue]); - const locationOptions = useMemo(() => locations, [locations]); - - const isSaving = createMutation.isPending || updateMutation.isPending; const isLoading = isEdit && isLoadingDetail; + useEffect(() => { + if (assignVendorRequested && !isLoading) { + const node = vendorSectionRef.current; + if (node) { + node.scrollIntoView({ behavior: "smooth", block: "start" }); + node.focus(); + } + } + }, [assignVendorRequested, isLoading]); + const onSubmit = (values: WorkOrderFormSchemaValues) => { - if (isEdit && id) { - updateMutation.mutate(values); + if (scheduleBounds.kind === "invalid") { return; } - createMutation.mutate(values); + saveMutation.mutate({ + workOrderId: isEdit ? id : undefined, + values, + vendorAssignment: + selectedVendorId == null + ? undefined + : { + vendorId: selectedVendorId, + scheduledStart: scheduleBounds.startIso, + scheduledEnd: scheduleBounds.endIso, + estimatedArrivalAt: estimatedArrivalAt + ? new Date(estimatedArrivalAt).toISOString() + : undefined, + etaManualOverride, + }, + }); }; const handleCancel = () => { navigate(isEdit && id ? `/workorders/${id}` : "/workorders"); }; + return { + id, + isEdit, + detail, + isLoading, + detailError, + isSaving: saveMutation.isPending, + register, + control, + handleSubmit, + errors, + locationOptions: locations, + isLoadingLocations, + employees, + problems, + trades, + subTrades, + tradeValue, + vendorSectionRef, + availableVendors, + isLoadingVendors, + selectedVendorId, + setSelectedVendorId, + estimatedArrivalAt, + setEstimatedArrivalAt, + etaManualOverride, + setEtaManualOverride, + scheduledStartLocal, + setScheduledStartLocal, + scheduledEndLocal, + setScheduledEndLocal, + scheduleBoundsError: scheduleBounds.error, + isScheduleBoundsInvalid: scheduleBounds.kind === "invalid", + onSubmit, + handleCancel, + }; +} + +export default function WorkOrderFormPage() { + const { + isEdit, + id, + detail, + isLoading, + detailError, + isSaving, + register, + control, + handleSubmit, + errors, + locationOptions, + isLoadingLocations, + employees, + problems, + trades, + subTrades, + tradeValue, + vendorSectionRef, + availableVendors, + isLoadingVendors, + selectedVendorId, + setSelectedVendorId, + estimatedArrivalAt, + setEstimatedArrivalAt, + etaManualOverride, + setEtaManualOverride, + scheduledStartLocal, + setScheduledStartLocal, + scheduledEndLocal, + setScheduledEndLocal, + scheduleBoundsError, + isScheduleBoundsInvalid, + onSubmit, + handleCancel, + } = useWorkOrderFormState(); + if (isLoading) { return ( @@ -118,9 +306,7 @@ export default function WorkOrderFormPage() { if (detailError) { return ( - - {detailError instanceof Error ? detailError.message : "Failed to load work order"} - + Unable to load this work order. Please try again. ); } @@ -131,232 +317,66 @@ export default function WorkOrderFormPage() { Work Orders - - {isEdit ? "Edit" : "Create"} - + {isEdit ? "Edit" : "Create"} - + {isEdit ? `Edit Work Order ${detail?.internalWONumber ?? id}` : "Create a Work Order"} - + - - Work Order Details - - - - - - ( - option.name} - value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null} - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - + - - Classification - - - Select Problem - {problems.map((option) => ( - - {option.value} - - ))} - - - Select Trade - {trades.map((option) => ( - - {option.value} - - ))} - - - - {tradeValue ? "Select Sub-Trade" : "Select a Trade first"} - - {subTrades.map((option) => ( - - {option.value} - - ))} - - - + - - Schedule & Priority - - - Select Priority - {WORK_ORDER_PRIORITY_OPTIONS.map((priority) => ( - - {priority} - - ))} - - - {WORK_ORDER_STATUS_OPTIONS.map((status) => ( - - {status} - - ))} - - ( - employee.name)} - value={field.value} - onChange={(_event, value) => field.onChange(value ?? "")} - onInputChange={(_event, value) => field.onChange(value)} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - - - - + - - Financial - - - - - - + - - Description - - + - + + + - diff --git a/src/app/v/[token]/dispatch/[id].tsx b/src/app/v/[token]/dispatch/[id].tsx index a7000843..c09d9b4b 100644 --- a/src/app/v/[token]/dispatch/[id].tsx +++ b/src/app/v/[token]/dispatch/[id].tsx @@ -1,405 +1,21 @@ import { useCallback, useState } from "react"; -import { Link, useParams } from "react-router"; +import { useParams } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { SignaturePad } from "@/app/v/_components/signature-pad"; -import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { Text } from "@/components/ui/text"; +import { DispatchDetailSections } from "@/app/v/[token]/dispatch/_components/dispatch-detail-sections"; +import { DispatchSummaryCard } from "@/app/v/[token]/dispatch/_components/dispatch-summary-card"; +import { useVendorPortal } from "@/app/v/_components/vendor-portal-context"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; -import { - formatVendorPortalDateTime, - vendorPortalStatusClass, -} from "@/domain/vendor-portal/lib/status-helpers"; -import type { - VendorPortalChecklistItem, - VendorPortalComment, - VendorPortalDispatchDetail, - VendorPortalSignoff, - VendorPortalUpliftRequest, -} from "@/domain/vendor-portal/types/vendor-portal"; +import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; import { queryKeys } from "@/infra/query-key/query-key"; -function ChecklistSection({ - token, - dispatchId, - items, - locked, - onItemUpdated, -}: { - token: string; - dispatchId: number; - items: VendorPortalChecklistItem[]; - locked: boolean; - onItemUpdated: (item: VendorPortalChecklistItem) => void; -}) { - const toggle = async (item: VendorPortalChecklistItem) => { - if (locked) return; - try { - const updated = await vendorPortalApi.updateChecklistItem( - token, - dispatchId, - item.id, - !item.isCompleted, - ); - onItemUpdated(updated); - } catch (error) { - alert(error instanceof Error ? error.message : "Failed to update checklist"); - } - }; - - if (!items.length) { - return
    No checklist items for this dispatch.
    ; - } - - return ( -
    - {items.map((item) => ( -
    - void toggle(item)} - disabled={locked} - /> - - {item.isCompleted && Boolean(item.completedAt) && ( - - {formatVendorPortalDateTime(item.completedAt)} - - )} -
    - ))} -
    - ); -} - -function CommentsSection({ - token, - dispatchId, - comments, - onAdded, -}: { - token: string; - dispatchId: number; - comments: VendorPortalComment[]; - onAdded: (comment: VendorPortalComment) => void; -}) { - const [text, setText] = useState(""); - const [submitting, setSubmitting] = useState(false); - - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!text.trim()) return; - setSubmitting(true); - try { - const added = await vendorPortalApi.addComment(token, dispatchId, text.trim()); - onAdded(added); - setText(""); - } catch (error) { - alert(error instanceof Error ? error.message : "Failed to post comment"); - } finally { - setSubmitting(false); - } - }; - - return ( - <> - {comments.length === 0 &&
    No comments yet.
    } - {comments.map((comment) => ( -
    -
    - {comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)} -
    -
    {comment.commenttext}
    -
    - ))} -
    void submit(event)} style={{ marginTop: 12 }}> -