diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png new file mode 100644 index 00000000..67a84215 Binary files /dev/null and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png new file mode 100644 index 00000000..d95ef194 Binary files /dev/null and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png index 45c3c047..8a7a6caf 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/dashboard/dashboard.visual.spec.ts b/e2e/dashboard/dashboard.visual.spec.ts new file mode 100644 index 00000000..ab51543b --- /dev/null +++ b/e2e/dashboard/dashboard.visual.spec.ts @@ -0,0 +1,256 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +function testUser(userRoles: string) { + return { + token: "dashboard-visual-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles, + phoneNumber: "5551234567", + fullname: "Dashboard Visual User", + id: "1", + }; +} + +const DISPATCHERS = [ + { id: "1", name: "Pat Dispatcher" }, + { id: "2", name: "Riley Chen" }, + { id: "3", name: "Sam Okafor" }, +]; + +const STATS = { + total: 128, + open: 52, + notDispatched: 8, + completed: 64, + dueCount: 40, + completedDueCount: 30, + completionRate: 82, + averageResolutionDays: 3.4, + scheduledTomorrow: 6, + pendingUplifts: 4, + avetaPending: 3, + breakdown: [ + { status: "PM", count: 24 }, + { status: "Emergency", count: 6 }, + { status: "Reactive", count: 12 }, + { status: "Overdue", count: 9 }, + ], + statusDistribution: [ + { status: "Scheduled", count: 30 }, + { status: "In Progress", count: 18 }, + { status: "Completed", count: 64 }, + { status: "Pending", count: 16 }, + ], +}; + +const WORKLOAD = { + items: [ + { + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + openWorkOrders: 12, + totalWorkOrders: 20, + }, + { dispatcherId: "2", dispatcherName: "Riley Chen", openWorkOrders: 7, totalWorkOrders: 15 }, + ], + page: 1, + pageSize: 10, + totalDispatchers: 2, +}; + +const PERFORMANCE = { + items: [ + { + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + completionRate: 94, + averageResolutionDays: 2.5, + assignedCount: 20, + completedCount: 18, + }, + { + dispatcherId: "2", + dispatcherName: "Riley Chen", + completionRate: 78, + averageResolutionDays: 3.1, + assignedCount: 15, + completedCount: 11, + }, + { + dispatcherId: "3", + dispatcherName: "Sam Okafor", + completionRate: 61, + averageResolutionDays: 4.4, + assignedCount: 9, + completedCount: 5, + }, + ], + page: 1, + pageSize: 10, + totalDispatchers: 3, +}; + +const REGIONS = { + items: [ + { region: "East", workOrderCount: 44 }, + { region: "Central", workOrderCount: 31 }, + { region: "West", workOrderCount: 22 }, + { region: "California", workOrderCount: 18 }, + { region: "Unmapped/Other", workOrderCount: 5 }, + ], +}; + +const VENDOR_INSIGHTS = { + items: [ + { + vendorId: "101", + vendorName: "Gateway Plumbing", + completionRate: 88, + rescheduleRate: 12, + averageResolutionDays: 2.9, + totalJobs: 40, + }, + { + vendorId: "102", + vendorName: "Summit HVAC", + completionRate: 76, + rescheduleRate: 20, + averageResolutionDays: 3.6, + totalJobs: 28, + }, + ], +}; + +const TREND = { + granularity: "week", + buckets: [ + { + date: "2026-07-27", + label: "Jul 27", + total: 18, + open: 4, + completed: 12, + canceled: 1, + overdue: 1, + isCurrent: false, + }, + { + date: "2026-08-03", + label: "Aug 3", + total: 22, + open: 6, + completed: 13, + canceled: 1, + overdue: 2, + isCurrent: false, + }, + { + date: "2026-08-10", + label: "Aug 10", + total: 26, + open: 8, + completed: 15, + canceled: 0, + overdue: 3, + isCurrent: false, + }, + { + date: "2026-08-17", + label: "Aug 17", + total: 20, + open: 9, + completed: 9, + canceled: 1, + overdue: 1, + isCurrent: true, + }, + ], +}; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function mockDashboardApi(page: Page, userRoles: string) { + await page.clock.setFixedTime(new Date(FROZEN_NOW)); + await page.addInitScript( + (user) => localStorage.setItem("auth", JSON.stringify(user)), + testUser(userRoles), + ); + + await page.route("**/api/**", async (route) => { + const pathname = new URL(route.request().url()).pathname; + + // Only stub real backend calls; let Vite dev module requests (e.g. + // /src/domain/auth/api/*.ts) through untouched. + if (!pathname.startsWith("/api/")) { + await route.continue(); + return; + } + + if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS }); + if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD }); + if (pathname.includes("/Dashboard/Performance")) + return fulfillJson(route, { data: PERFORMANCE }); + if (pathname.includes("/Dashboard/Regions")) return fulfillJson(route, { data: REGIONS }); + if (pathname.includes("/Dashboard/VendorInsights")) { + return fulfillJson(route, { data: VENDOR_INSIGHTS }); + } + if (pathname.includes("/Dashboard/Trend")) return fulfillJson(route, { data: TREND }); + if (pathname.includes("/workorders/lookups/dispatchers")) { + return fulfillJson(route, { data: DISPATCHERS }); + } + + return fulfillJson(route, { data: [] }); + }); +} + +async function openDashboard(page: Page, userRoles: string) { + await mockDashboardApi(page, userRoles); + await page.goto("/"); + await page.evaluate(() => document.fonts.ready); + await page.addStyleTag({ + content: ` + *, *::before, *::after { transition: none !important; animation: none !important; } + .MuiDrawer-paper { transform: none !important; } + .Toastify__toast-container { display: none !important; } + .tsqd-parent-container { display: none !important; } + `, + }); +} + +async function expectStableScreenshot(page: Page, name: string) { + await page.waitForTimeout(250); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect(page).toHaveScreenshot(name, { fullPage: true }); +} + +test.describe("Dashboard deterministic pixel regression", () => { + test("admin dashboard", async ({ page }) => { + await openDashboard(page, "Admin"); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByText("Status Distribution")).toBeVisible(); + await expect(page.getByText("Dispatcher Performance")).toBeVisible(); + // The scope picker is available to Admin/Scheduler (SH-336). + await expect(page.getByRole("button", { name: /All dispatchers/ })).toBeVisible(); + await expectStableScreenshot(page, "dashboard-admin.png"); + }); + + test("dispatcher scoped dashboard", async ({ page }) => { + await openDashboard(page, "Dispatcher"); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + // A Dispatcher gets the static "My WOs" label and no dispatcher tables (SH-336). + await expect(page.getByText("My WOs")).toBeVisible(); + await expect(page.getByText("Dispatcher Performance")).toHaveCount(0); + await expect(page.getByText("Status Distribution")).toBeVisible(); + await expectStableScreenshot(page, "dashboard-dispatcher.png"); + }); +}); diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index 18938fd4..69205a2e 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -268,7 +268,7 @@ test("vendor revises a changes-requested uplift using its existing evidence", as }); }); -test("internal approver can approve and request changes with audited notes", async ({ page }) => { +test("internal approver can approve, reject, and revoke with audited notes", async ({ page }) => { const decisions: Array<{ path: string; body: unknown }> = []; const statuses = new Map([ [41, "Pending"], @@ -284,73 +284,95 @@ test("internal approver can approve and request changes with audited notes", asy await page.route(/\/api\/uplifts(?:[/?]|$)/, async (route) => { const request = route.request(); const path = new URL(request.url()).pathname; - const action = path.match(/\/uplifts\/(41|42)\/(approve|request-changes)$/); + const action = path.match(/\/uplifts\/(41|42)\/(approve|reject|revoke)$/); if (action && request.method() === "POST") { const id = Number(action[1]); - statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested"); + statuses.set( + id, + action[2] === "approve" ? "Approved" : action[2] === "revoke" ? "Revoked" : "Rejected", + ); decisions.push({ path, body: request.postDataJSON() }); await fulfillJson(route, { id, status: statuses.get(id) }); return; } + if (path.endsWith("/uplifts/can-approve")) { + await fulfillJson(route, { canApprove: true }); + return; + } + + const requestedStatus = new URL(request.url()).searchParams.get("status"); + const items = [ + { + id: 41, + dispatchId: 7, + workOrderId: 501, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + currentNTE: 100, + requestedNTE: 125, + delta: 25, + requiredTier: 1, + status: statuses.get(41), + canDecide: true, + evidenceDocumentId: 22, + evidenceFileName: "estimate.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 2048, + }, + { + id: 42, + dispatchId: 8, + workOrderId: 502, + dispatchNumber: "DSP-42", + poNumber: "PO-42", + vendorCompanyName: "Metro HVAC", + currentNTE: 200, + requestedNTE: 260, + delta: 60, + requiredTier: 2, + status: statuses.get(42), + canDecide: true, + evidenceDocumentId: 23, + evidenceFileName: "quote.pdf", + }, + ].filter((item) => requestedStatus == null || item.status === requestedStatus); + await fulfillJson(route, { - total: 2, - items: [ - { - id: 41, - dispatchId: 7, - dispatchNumber: "DSP-41", - poNumber: "PO-41", - vendorCompanyName: "Gateway Plumbing", - currentNTE: 100, - requestedNTE: 125, - delta: 25, - requiredTier: 1, - status: statuses.get(41), - canDecide: true, - evidenceDocumentId: 22, - evidenceFileName: "estimate.pdf", - evidenceContentType: "application/pdf", - evidenceSizeBytes: 2048, - }, - { - id: 42, - dispatchId: 8, - dispatchNumber: "DSP-42", - poNumber: "PO-42", - vendorCompanyName: "Metro HVAC", - currentNTE: 200, - requestedNTE: 260, - delta: 60, - requiredTier: 2, - status: statuses.get(42), - canDecide: true, - evidenceDocumentId: 23, - evidenceFileName: "quote.pdf", - }, - ], + total: items.length, + items, }); }); await page.goto("/uplifts"); - await expect(page.getByRole("heading", { name: "Uplift Queue" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Uplift Approvals" })).toBeVisible(); const approvalRow = page.getByRole("row").filter({ hasText: "DSP-41" }); await approvalRow.getByRole("button", { name: "Approve" }).click(); - await page.getByRole("button", { name: "Confirm" }).click(); const changesRow = page.getByRole("row").filter({ hasText: "DSP-42" }); - await changesRow.getByRole("button", { name: "Request Changes" }).click(); - await page.getByLabel("Note (required)").fill("Please itemize labor and parts."); - await page.getByRole("button", { name: "Confirm" }).click(); + await changesRow.getByRole("button", { name: "Reject" }).click(); + await page.getByLabel("Reason for rejection").fill("Please itemize labor and parts."); + await page.getByRole("button", { name: "Reject uplift" }).click(); + + await page.getByRole("tab", { name: "Approved" }).click(); + const approvedRow = page.getByRole("row").filter({ hasText: "DSP-41" }); + await approvedRow.getByRole("button", { name: "Revoke" }).click(); + await page.getByLabel("Reason for revoking").fill("Approval was made in error."); + await page.getByRole("button", { name: "Revoke $125" }).click(); await expect .poll(() => decisions) .toEqual([ { path: "/api/uplifts/41/approve", body: {} }, { - path: "/api/uplifts/42/request-changes", + path: "/api/uplifts/42/reject", body: { note: "Please itemize labor and parts." }, }, + { + path: "/api/uplifts/41/revoke", + body: { note: "Approval was made in error." }, + }, ]); }); diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts new file mode 100644 index 00000000..a8fde88f --- /dev/null +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -0,0 +1,181 @@ +import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test"; + +// Completion uploads from a mobile-sized viewport must reach the API as +// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category. +test.use({ ...devices["Pixel 7"] }); + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; +const ROW_VERSION = "AAAAAAAAB9E="; + +const TEST_USER = { + token: "wo-completion-uploads-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +// Leading bytes of files written by real encoders (ffmpeg, iPhone .MOV, pdf). +const PDF = Buffer.from("%PDF-1.7\n%âã\n1 0 obj\n", "latin1"); +const JPEG = Buffer.from("ffd8ffe000104a46494600010200000100010000", "hex"); +const MP4 = Buffer.from("000000206674797069736f6d0000020069736f6d69736f32", "hex"); +const MOV = Buffer.from("000000146674797071742020000002007174202000000008", "hex"); + +type BoardRowOptions = { id: number; woNumber: string; workOrderType: number }; + +function boardRow({ id, woNumber, workOrderType }: BoardRowOptions) { + return { + id, + woNumber, + workOrderType, + siteCode: "BK5", + lifecycleStatusLabel: "In Progress", + scheduledDate: "2026-08-19", + dispatcherId: "1", + dispatcherName: "Pat Dispatcher", + pm: "HVAC", + docStatus: "No", + rowVersion: ROW_VERSION, + }; +} + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function multipartBoundary(request: Request): string | undefined { + const contentType = request.headers()["content-type"] ?? ""; + return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; +} + +async function hideQueryDevtools(page: Page) { + await page.addStyleTag({ + content: + ".tsqd-parent-container { display: none !important; pointer-events: none !important; }", + }); + await page.locator(".tsqd-parent-container").evaluateAll((nodes) => { + for (const node of nodes) { + node.remove(); + } + }); +} + +async function mockBoard(page: Page, row: ReturnType) { + await page.route("**/api/**", (route) => { + const pathname = new URL(route.request().url()).pathname; + if (!pathname.startsWith("/api/")) return route.fallback(); + return fulfillJson(route, { data: [] }); + }); + await page.route("**/api/workorders/board*", (route) => { + if (route.request().url().includes("board/search")) return route.fallback(); + return fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 1, total: 1 }, + unscheduled: [], + scheduled: [row], + }); + }); + await page.route("**/api/workorders/board/search*", (route) => + fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }), + ); +} + +async function openCompletionTab(page: Page, woNumber: string) { + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + await page.goto("/workorders"); + await hideQueryDevtools(page); + const row = page.getByRole("row").filter({ hasText: woNumber }); + await row.getByRole("button", { name: "View details" }).click(); + const panel = page.getByRole("dialog", { name: "Work order details" }); + await expect(panel).toBeVisible({ timeout: 15_000 }); + await panel.getByRole("tab", { name: "Completion Doc" }).click(); + return panel; +} + +test.describe("Completion uploads on a mobile viewport", () => { + test("uploads a signed PDF as multipart with the file and work order version", async ({ + page, + }) => { + const row = boardRow({ id: 71, woNumber: "PM-UPLOAD-71", workOrderType: 2 }); + await mockBoard(page, row); + await page.route("**/api/workorders/71/detail", (route) => + fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }), + ); + let upload: Request | undefined; + await page.route("**/api/workorders/71/completion-doc", (route) => { + upload = route.request(); + return fulfillJson(route, { docStatus: "Yes", signOffAttachment: "/Assets/Documents/x.pdf" }); + }); + + const panel = await openCompletionTab(page, row.woNumber); + const chooser = page.waitForEvent("filechooser"); + await panel.getByRole("button", { name: "Upload signed PDF" }).click(); + await ( + await chooser + ).setFiles({ name: "Signed Completion.pdf", mimeType: "application/pdf", buffer: PDF }); + + await expect.poll(() => upload?.method()).toBe("POST"); + const boundary = multipartBoundary(upload!); + expect(boundary, upload!.headers()["content-type"]).toBeTruthy(); + const body = upload!.postDataBuffer()?.toString("latin1") ?? ""; + expect(body).toContain(`--${boundary}`); + expect(body).toContain('name="file"; filename="Signed Completion.pdf"'); + expect(body).toContain("%PDF-1.7"); + expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); + }); + + test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => { + const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 }); + const media: { id: number; category: number; url: string; fileName: string }[] = []; + await mockBoard(page, row); + await page.route("**/api/workorders/72/detail", (route) => + fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }), + ); + const uploads: { contentType: string; body: string }[] = []; + await page.route("**/api/workorders/72/media", (route) => { + const request = route.request(); + const body = request.postDataBuffer()?.toString("latin1") ?? ""; + uploads.push({ contentType: request.headers()["content-type"] ?? "", body }); + const fileName = /filename="([^"]+)"/.exec(body)?.[1] ?? "file"; + const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName }; + media.push(item); + return fulfillJson(route, item); + }); + let categorize: Request | undefined; + await page.route("**/api/workorders/72/media/*", (route) => { + categorize = route.request(); + const item = media[0]!; + return fulfillJson(route, { ...item, category: 1 }); + }); + + const panel = await openCompletionTab(page, row.woNumber); + const chooser = page.waitForEvent("filechooser"); + await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click(); + await ( + await chooser + ).setFiles([ + { name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG }, + { name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 }, + { name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV }, + ]); + + await expect.poll(() => uploads.length).toBe(3); + for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) { + expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/); + expect(uploads[index]!.body).toContain(`filename="${name}"`); + } + + await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before"); + await expect.poll(() => categorize?.method()).toBe("PATCH"); + const boundary = multipartBoundary(categorize!); + expect(boundary, categorize!.headers()["content-type"]).toBeTruthy(); + const body = categorize!.postDataBuffer()?.toString("latin1") ?? ""; + expect(body).toMatch(/name="category"\r\n\r\nBefore/); + expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`)); + }); +}); diff --git a/e2e/work-orders/wizard-vendor-company-notes.spec.ts b/e2e/work-orders/wizard-vendor-company-notes.spec.ts new file mode 100644 index 00000000..df507616 --- /dev/null +++ b/e2e/work-orders/wizard-vendor-company-notes.spec.ts @@ -0,0 +1,168 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const FROZEN_NOW = "2026-08-19T15:00:00.000Z"; + +const TEST_USER = { + token: "wizard-vendor-notes-e2e-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Work Order E2E Admin", + id: "1", +}; + +const LOCATIONS = [ + { + id: 10, + name: "STL-01 · St. Louis, MO", + siteCode: "STL-01", + city: "St. Louis", + state: "MO", + code: "STL-01", + }, +]; + +const VENDORS = [ + { + id: 101, + companyName: "Gateway Plumbing", + contactName: "Adam Whyte", + tradeSpecialties: "Plumbing", + address: "1 Market St", + }, +]; + +type Captured = { method: string; path: string; body: unknown }; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function mockApi(page: Page, captured: Captured[]) { + await page.route( + (url) => url.pathname.startsWith("/api/"), + async (route) => { + const request = route.request(); + const { pathname } = new URL(request.url()); + if (request.method() !== "GET") { + captured.push({ method: request.method(), path: pathname, body: request.postDataJSON() }); + } + if (pathname.endsWith("/workorders/board") && request.method() === "POST") { + await fulfillJson(route, { data: { id: 900, woNumber: "SH00900" } }); + return; + } + if (pathname.endsWith("/vendor-company-roster/55") && request.method() === "PATCH") { + await fulfillJson(route, { + companyId: 55, + rowVersion: "rv-2", + name: "Gateway Plumbing", + notes: "Gate code 4411", + technicians: [], + }); + return; + } + if (pathname.endsWith("/vendor-company-roster") && request.method() === "GET") { + await fulfillJson(route, { + companyId: 55, + rowVersion: "rv-1", + name: "Gateway Plumbing", + notes: "Call before arrival", + technicians: [{ id: 101, contactName: "Adam Whyte", isActive: true }], + }); + return; + } + if (pathname.includes("workorders/board")) { + await fulfillJson(route, { + weekStart: "2026-08-17", + weekEnd: "2026-08-21", + counts: { returned: 0, total: 0 }, + unscheduled: [], + scheduled: [], + items: [], + }); + return; + } + if (pathname.endsWith("/locations/10")) { + await fulfillJson(route, { + data: { + id: 10, + name: "STL-01 · St. Louis, MO", + contact: "Jamie Site", + phone: "3145550100", + zipCode: "63101", + }, + }); + return; + } + if (pathname.includes("/locations/sites") || pathname.includes("/Contact/GetLocations")) { + await fulfillJson(route, { data: LOCATIONS }); + return; + } + if (pathname.toLowerCase().includes("/vendors/dropdown")) { + await fulfillJson(route, { data: VENDORS }); + return; + } + await fulfillJson(route, { data: [], items: [] }); + }, + ); +} + +test.describe("wizard vendor company notes (SH-321)", () => { + test.setTimeout(60_000); + + test("pre-fills the company notes and saves edits to the vendor company on create", async ({ + page, + }) => { + const captured: Captured[] = []; + await page.clock.install({ time: new Date(FROZEN_NOW) }); + await page.clock.resume(); + await page.addInitScript((user) => { + localStorage.setItem("auth", JSON.stringify(user)); + const style = document.createElement("style"); + style.textContent = ".tsqd-parent-container { display: none !important; }"; + document.documentElement.appendChild(style); + }, TEST_USER); + await mockApi(page, captured); + + await page.goto("/workorders"); + await page.getByRole("button", { name: "New WO" }).click(); + await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /Select site/ }).click(); + await page.getByRole("button", { name: "STL-01 · St. Louis, MO" }).first().click(); + await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("button", { name: /Select technician/ }).click(); + await page + .getByRole("button", { name: /Adam Whyte/ }) + .last() + .first() + .click(); + + const notes = page.getByPlaceholder("Contact preferences, availability, etc."); + await expect(notes).toHaveValue("Call before arrival"); + await notes.fill("Gate code 4411"); + + await page.getByRole("button", { name: /Create/ }).click(); + + await expect + .poll(() => captured.find((c) => c.method === "PATCH")) + .toEqual({ + method: "PATCH", + path: "/api/vendor-company-roster/55", + body: { + rowVersion: "rv-1", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }, + }); + expect(captured.some((c) => c.method === "POST" && c.path.endsWith("/workorders/board"))).toBe( + true, + ); + }); +}); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index d7b17c18..64405aa6 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -80,6 +80,7 @@ export const API_PATHS = { teamMember: { getList: "User", create: "team-members", + detail: "team-members", }, contact: { getList: "Contact/GetContactList", @@ -152,6 +153,11 @@ export const API_PATHS = { }, dashboard: { stats: "Dashboard/Stats", + workload: "Dashboard/Workload", + performance: "Dashboard/Performance", + regions: "Dashboard/Regions", + vendorInsights: "Dashboard/VendorInsights", + trend: "Dashboard/Trend", }, dropdownOptions: { byCategory: "DropdownOptions/ByCategory", diff --git a/src/api/api.ts b/src/api/api.ts index 147c0571..e85e8e18 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -11,28 +11,22 @@ function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); } -/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */ -export function shouldStripJsonContentType(body: unknown): boolean { - return body instanceof FormData; -} - export const api: KyInstance = ky.create({ prefix: resolveApiPrefixUrl(env.apiUrl), + // hazard: no instance-wide Content-Type. Ky sets application/json for `json` bodies, and the + // runtime must set `multipart/form-data; boundary=…` for FormData while it builds the Request. + // A default here is baked into that Request; deleting it in a hook leaves the multipart body + // with no Content-Type at all, so ASP.NET cannot bind [FromForm] fields. headers: { Accept: "application/json", - "Content-Type": "application/json", }, hooks: { beforeRequest: [ - ({ request, options }) => { + ({ request }) => { const token = getAccessToken(); if (token) { request.headers.set("Authorization", token); } - // Let the runtime set multipart boundary; default JSON Content-Type breaks FormData. - if (shouldStripJsonContentType(options.body)) { - request.headers.delete("Content-Type"); - } }, ], beforeError: [({ error }) => normalizeApiRequestError(error)], diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx deleted file mode 100644 index e1af6bf9..00000000 --- a/src/app/(protected)/dashboard.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type { ReactNode } from "react"; -import { useNavigate } from "react-router"; -import AddIcon from "@mui/icons-material/Add"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined"; -import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined"; -import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; -import { - Alert, - Box, - CircularProgress, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; - -const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const; - -const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const; - -interface KpiCardProps { - title: string; - description?: string; - value?: string | number; - icon: ReactNode; - color: "success" | "primary" | "info" | "warning"; - onClick: () => void; - loading?: boolean; -} - -function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) { - const colorMap = { - success: "success.main", - primary: "primary.main", - info: "info.main", - warning: "warning.main", - } as const; - - return ( - - - - {icon} - - - - - {title} - - {value !== undefined && ( - - {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} - - )} - {Boolean(description) && ( - - {description} - - )} - - - - ); -} - -export default function DashboardPage() { - const navigate = useNavigate(); - const { data: stats, isLoading, isFetching, error } = useDashboardStats(); - - const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); - - return ( - - - - Dashboard - - {isFetching && !isLoading && } - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load dashboard stats"} - - )} - - } - color="success" - onClick={() => navigate("/workorders/new")} - /> - } - color="primary" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="info" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="warning" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - - - - - Priority Analysis - - - Age of open Work Orders by Priority - - - - - - - - Priority - - Total - - - 0-14 Days - - - 15-29 Days - - - 30-59 Days - - - 60+ Days - - - - - {PRIORITY_LEVELS.map((priority) => ( - - {priority} - — - — - — - — - — - - ))} - -
-
-
- - - - Invoice Analysis - - - - - - - - Status - - Count - - - Amount - - - - - {INVOICE_STATUSES.map((status) => ( - - {status} - 0 - $0.00 - - ))} - -
-
-
-
- ); -} diff --git a/src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx b/src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx new file mode 100644 index 00000000..f449a957 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx @@ -0,0 +1,86 @@ +import { Box, ButtonBase } from "@mui/material"; +import { Text } from "@/components/ui/text"; + +export interface DashboardBarListRow { + id: string; + label: string; + count: number; +} + +interface DashboardBarListProps { + rows: DashboardBarListRow[]; + onSelectRow?: (row: DashboardBarListRow) => void; + isSelectable?: (row: DashboardBarListRow) => boolean; + ariaLabel: string; +} + +function percentOf(count: number, max: number): number { + if (max <= 0 || count <= 0) { + return 0; + } + + return Math.round((count / max) * 100); +} + +function BarListRow({ row, width }: { row: DashboardBarListRow; width: number }) { + return ( + + + + {row.label} + + + {row.count.toLocaleString()} + + + + {width > 0 && ( + + )} + + + ); +} + +export function DashboardBarList({ + rows, + onSelectRow, + isSelectable, + ariaLabel, +}: DashboardBarListProps) { + const max = Math.max(...rows.map((row) => row.count), 0); + + return ( + + {rows.map((row) => { + const width = percentOf(row.count, max); + const selectable = Boolean(onSelectRow) && row.count > 0 && (isSelectable?.(row) ?? true); + + return selectable ? ( + onSelectRow?.(row)} + sx={{ + width: "100%", + textAlign: "left", + borderRadius: 1, + px: 1, + mx: -1, + cursor: "pointer", + }} + > + + + ) : ( + + + + ); + })} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-card.tsx new file mode 100644 index 00000000..46938810 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-card.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; +import { Box, CircularProgress, Paper } from "@mui/material"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; + +interface DashboardCardProps { + title: string; + /** Optional scope caption shown beside the title (e.g. SH-349's all-time note). */ + caption?: string; + isLoading?: boolean; + error?: unknown; + isEmpty: boolean; + emptyMessage: string; + children: ReactNode; +} + +export function DashboardCard({ + title, + caption, + isLoading = false, + error, + isEmpty, + emptyMessage, + children, +}: DashboardCardProps) { + return ( + + + {title} + {!!caption && ( + + {caption} + + )} + + {isLoading && ( + + + + )} + {!isLoading && Boolean(error) && } + {!isLoading && !error && isEmpty && ( + + + {emptyMessage} + + + )} + {!isLoading && !error && !isEmpty && {children}} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-header-actions.tsx b/src/app/(protected)/dashboard/_components/dashboard-header-actions.tsx new file mode 100644 index 00000000..e410c72b --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-header-actions.tsx @@ -0,0 +1,67 @@ +import { Box, Button, CircularProgress } from "@mui/material"; +import { Users } from "lucide-react"; +import { Text } from "@/components/ui/text"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { DashboardRangeToggle } from "./dashboard-range-toggle"; +import { DashboardScopePicker } from "./dashboard-scope-picker"; +import type { DashboardScope } from "./dashboard-scope"; + +interface DashboardHeaderActionsProps { + isRefreshing: boolean; + showReset: boolean; + onResetFilters: () => void; + canViewAll: boolean; + scope: DashboardScope; + onScopeChange: (scope: DashboardScope) => void; + dispatcherUsers: WorkOrderUser[]; + dispatcherUsersLoading: boolean; + currentUserId: string | number | undefined; + range: DashboardRangeKey; + onRangeChange: (range: DashboardRangeKey) => void; +} + +export function DashboardHeaderActions({ + isRefreshing, + showReset, + onResetFilters, + canViewAll, + scope, + onScopeChange, + dispatcherUsers, + dispatcherUsersLoading, + currentUserId, + range, + onRangeChange, +}: DashboardHeaderActionsProps) { + return ( + + {isRefreshing && } + {showReset && ( + + )} + {canViewAll ? ( + + ) : ( + + + + My WOs + + + )} + + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx new file mode 100644 index 00000000..69d29c58 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { Box, ButtonBase, Paper } from "@mui/material"; +import { Text } from "@/components/ui/text"; + +type KpiTone = "success" | "primary" | "info" | "warning"; + +const toneColor: Record = { + success: "success.main", + primary: "primary.main", + info: "info.main", + warning: "warning.main", +}; + +interface DashboardKpiCardProps { + title: string; + value?: number; + icon: ReactNode; + tone: KpiTone; + loading?: boolean; + active?: boolean; + onSelect?: () => void; +} + +export function DashboardKpiCard({ + title, + value, + icon, + tone, + loading = false, + active = false, + onSelect, +}: DashboardKpiCardProps) { + const color = toneColor[tone]; + const content = ( + + + {icon} + + + + {title} + + + {loading ? "…" : (value ?? 0).toLocaleString()} + + + + ); + + return ( + + {active && Boolean(onSelect) ? ( + + {content} + + ) : ( + content + )} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx b/src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx new file mode 100644 index 00000000..b97cb672 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx @@ -0,0 +1,42 @@ +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import { DASHBOARD_RANGES, type DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; + +const RANGE_LABELS: Record = { + "this-week": "This week", + "last-3-months": "Last 3 months", + today: "Today", + "all-time": "All time", +}; + +interface DashboardRangeToggleProps { + value: DashboardRangeKey; + onChange: (value: DashboardRangeKey) => void; + disabled?: boolean; +} + +export function DashboardRangeToggle({ + value, + onChange, + disabled = false, +}: DashboardRangeToggleProps) { + return ( + { + if (next) { + onChange(next); + } + }} + > + {DASHBOARD_RANGES.map((range) => ( + + {RANGE_LABELS[range]} + + ))} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-scope-picker.tsx b/src/app/(protected)/dashboard/_components/dashboard-scope-picker.tsx new file mode 100644 index 00000000..66c63dae --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-scope-picker.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { Popover, Skeleton } from "@mui/material"; +import { ChevronDown, Users } from "lucide-react"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; +import type { DashboardScope } from "./dashboard-scope"; + +type DashboardScopePickerProps = { + scope: DashboardScope; + onChange: (scope: DashboardScope) => void; + users: WorkOrderUser[]; + usersLoading?: boolean; + currentUserId?: string | number; +}; + +export function DashboardScopePicker({ + scope, + onChange, + users, + usersLoading = false, + currentUserId, +}: DashboardScopePickerProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + + const dispatchers = useMemo( + () => [...users].sort((a, b) => a.name.localeCompare(b.name)), + [users], + ); + + const label = useMemo(() => { + if (scope.mode === "all") return "All dispatchers"; + if (scope.mode === "mine") return "My WOs"; + return dispatchers.find((user) => String(user.id) === scope.id)?.name ?? "Dispatcher"; + }, [scope, dispatchers]); + + const select = (next: DashboardScope) => { + onChange(next); + close(); + }; + + if (usersLoading && users.length === 0) { + return ( + + ); + } + + return ( + <> + + + {label} + + + + + select({ mode: "all" })} + /> + + {meId != null && ( + select({ mode: "mine" })} + /> + )} + + {dispatchers.map((user) => { + const id = String(user.id); + return ( + me + ) : undefined + } + onClick={() => select({ mode: "individual", id })} + /> + ); + })} + + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-scope.ts b/src/app/(protected)/dashboard/_components/dashboard-scope.ts new file mode 100644 index 00000000..62e6e7c8 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-scope.ts @@ -0,0 +1,21 @@ +/** + * SH-336 dispatcher-scope selection. Single-select — the Dashboard scopes to at + * most one dispatcher at a time (All company / My WOs / one individual), unlike + * the board's multi-select dispatcher filter. + */ +export type DashboardScope = + { mode: "all" } | { mode: "mine" } | { mode: "individual"; id: string }; + +export const DASHBOARD_SCOPE_ALL: DashboardScope = { mode: "all" }; + +/** Resolves the scope to the `dispatcherId` query param, or null for company-wide. */ +export function scopeDispatcherId( + scope: DashboardScope, + currentUserId: string | number | undefined, +): string | null { + if (scope.mode === "all") return null; + if (scope.mode === "mine") { + return currentUserId != null && currentUserId !== "" ? String(currentUserId) : null; + } + return scope.id || null; +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx new file mode 100644 index 00000000..6b273323 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from "react"; +import { Box, IconButton } from "@mui/material"; +import ArrowBackOutlinedIcon from "@mui/icons-material/ArrowBackOutlined"; +import ArrowForwardOutlinedIcon from "@mui/icons-material/ArrowForwardOutlined"; +import { Text } from "@/components/ui/text"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardTrendPoint } from "@/domain/dashboard/types/dashboard-trend"; +import { useDashboardTrend } from "@/domain/dashboard/use-cases/use-dashboard-trend"; +import { + businessTodayIso, + dashboardRangeParams, +} from "@/domain/dashboard/utils/dashboard-range-utils"; +import { DashboardCard } from "./dashboard-card"; + +interface DashboardTrendCardProps { + range: DashboardRangeKey; +} + +function trendParams(range: DashboardRangeKey, year: number) { + if (range === "all-time") return { year }; + const params = dashboardRangeParams(range); + return { + ...params, + ...(range === "last-3-months" ? { range: "3m" as const } : {}), + }; +} + +function barHeight(point: DashboardTrendPoint, max: number): number { + if (point.total <= 0 || max <= 0) return 0; + return Math.max(6, Math.round((point.total / max) * 100)); +} + +function TrendBar({ point, max }: { point: DashboardTrendPoint; max: number }) { + const height = barHeight(point, max); + return ( + + + {point.total.toLocaleString()} + + + {height > 0 && ( + + )} + + + {point.label} + + {point.isCurrent && Today} + + ); +} + +export function DashboardTrendCard({ range }: DashboardTrendCardProps) { + const currentYear = Number(businessTodayIso().slice(0, 4)); + const [year, setYear] = useState(currentYear); + const params = useMemo(() => trendParams(range, year), [range, year]); + const trend = useDashboardTrend(params); + const points = trend.data?.points ?? []; + const max = Math.max(...points.map((point) => point.total), 0); + + return ( + + + + + {trend.data?.granularity ? `${trend.data.granularity} buckets` : "Trend"} + + {range === "all-time" && ( + + setYear((value) => value - 1)} + > + + + {year} + setYear((value) => value + 1)} + > + + + + )} + + + {points.map((point) => ( + + ))} + + + The dashed marker and “Today” label identify the current business date. + + + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx new file mode 100644 index 00000000..eb491726 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx @@ -0,0 +1,139 @@ +import { useNavigate } from "react-router"; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; +import { + workOrderDispatcherDrilldownSearch, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardCard } from "./dashboard-card"; +import { DispatcherTablePager } from "./dispatcher-table-pager"; + +const EMPTY_MESSAGE = "No dispatchers are set up yet."; + +interface DispatcherPerformanceCardProps { + rows: DispatcherPerformanceRow[]; + range: DashboardRangeKey; + page: number; + pageSize: number; + totalDispatchers: number; + onPageChange: (page: number) => void; + isLoading?: boolean; + error?: unknown; +} + +function formatDays(value: number): string { + return (Math.round(value * 10) / 10).toLocaleString(); +} + +// SH-347: colour the completion rate green at 90+, amber 70–89, red below 70. +function completionRateColor(rate: number): string { + if (rate >= 90) return "success.main"; + if (rate >= 70) return "warning.main"; + return "error.main"; +} + +function DispatcherNameCell({ + row, + range, +}: { + row: DispatcherPerformanceRow; + range: DashboardRangeKey; +}) { + const navigate = useNavigate(); + // A listed dispatcher is one with work in range; the drill-down filters Work + // Orders by dispatcher + range and never uses the completion rate, so gate on + // identity rather than the rate. (A dispatcher with assigned work but a 0% + // rate was previously inert here while drillable from Workload.) The gate stays + // on identity rather than `assignedCount > 0` so a legacy response without the + // SH-347 count field (assignedCount → 0) keeps its rows drillable. + const drillable = Boolean(row.dispatcherId); + + if (!drillable) { + return {row.dispatcherName}; + } + + return ( + + navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range))) + } + > + {row.dispatcherName} + + ); +} + +export function DispatcherPerformanceCard({ + rows, + range, + page, + pageSize, + totalDispatchers, + onPageChange, + isLoading = false, + error, +}: DispatcherPerformanceCardProps) { + return ( + + + + + + Dispatcher + + Assigned + + + Completed + + + Completion % + + + Avg days + + + + + {rows.map((row) => ( + + + + + {row.assignedCount.toLocaleString()} + {row.completedCount.toLocaleString()} + + {`${row.completionRate.toLocaleString()}%`} + + + {row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)} + + + ))} + +
+
+ +
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-table-pager.tsx b/src/app/(protected)/dashboard/_components/dispatcher-table-pager.tsx new file mode 100644 index 00000000..b0837efb --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dispatcher-table-pager.tsx @@ -0,0 +1,40 @@ +import { Box, Pagination } from "@mui/material"; + +interface DispatcherTablePagerProps { + page: number; + pageSize: number; + totalDispatchers: number; + onPageChange: (page: number) => void; + label: string; +} + +/** + * 10-row pager for the dispatcher tables (SH-346/SH-347). Renders nothing until + * there is more than one page, so single-page tables are unchanged. + */ +export function DispatcherTablePager({ + page, + pageSize, + totalDispatchers, + onPageChange, + label, +}: DispatcherTablePagerProps) { + const pageCount = pageSize > 0 ? Math.ceil(totalDispatchers / pageSize) : 1; + + if (pageCount <= 1) { + return null; + } + + return ( + + onPageChange(value)} + aria-label={label} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-tables-section.tsx b/src/app/(protected)/dashboard/_components/dispatcher-tables-section.tsx new file mode 100644 index 00000000..05e1f301 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dispatcher-tables-section.tsx @@ -0,0 +1,60 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import { + DISPATCHER_PAGE_SIZE, + type DispatcherPage, +} from "@/domain/dashboard/types/dashboard-dispatcher-page"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; +import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload"; +import { DispatcherPerformanceCard } from "./dispatcher-performance-card"; +import { DispatcherWorkloadCard } from "./dispatcher-workload-card"; + +interface DispatcherTablesSectionProps { + range: DashboardRangeKey; + workload: UseQueryResult, Error>; + performance: UseQueryResult, Error>; + workloadPage: number; + performancePage: number; + onWorkloadPageChange: (page: number) => void; + onPerformancePageChange: (page: number) => void; +} + +/** + * The two paged dispatcher tables. Kept as a fragment so both cards stay direct + * children of the dashboard grid, and so the paging view-model derivation lives + * here rather than inflating the page component. + */ +export function DispatcherTablesSection({ + range, + workload, + performance, + workloadPage, + performancePage, + onWorkloadPageChange, + onPerformancePageChange, +}: DispatcherTablesSectionProps) { + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx new file mode 100644 index 00000000..99980df2 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx @@ -0,0 +1,107 @@ +import { useNavigate } from "react-router"; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload"; +import { + workOrderDispatcherDrilldownSearch, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardCard } from "./dashboard-card"; +import { DispatcherTablePager } from "./dispatcher-table-pager"; + +const EMPTY_MESSAGE = "No dispatchers are set up yet."; + +interface DispatcherWorkloadCardProps { + rows: DispatcherWorkloadRow[]; + range: DashboardRangeKey; + page: number; + pageSize: number; + totalDispatchers: number; + onPageChange: (page: number) => void; + isLoading?: boolean; + error?: unknown; +} + +function DispatcherNameCell({ + row, + range, +}: { + row: DispatcherWorkloadRow; + range: DashboardRangeKey; +}) { + const navigate = useNavigate(); + const drillable = Boolean(row.dispatcherId) && row.totalWorkOrders > 0; + + if (!drillable) { + return {row.dispatcherName}; + } + + return ( + + navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range))) + } + > + {row.dispatcherName} + + ); +} + +export function DispatcherWorkloadCard({ + rows, + range, + page, + pageSize, + totalDispatchers, + onPageChange, + isLoading = false, + error, +}: DispatcherWorkloadCardProps) { + return ( + + + + + + Dispatcher + + Open + + + Total + + + + + {rows.map((row) => ( + + + + + {row.openWorkOrders.toLocaleString()} + {row.totalWorkOrders.toLocaleString()} + + ))} + +
+
+ +
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/status-distribution-card.tsx b/src/app/(protected)/dashboard/_components/status-distribution-card.tsx new file mode 100644 index 00000000..956735eb --- /dev/null +++ b/src/app/(protected)/dashboard/_components/status-distribution-card.tsx @@ -0,0 +1,55 @@ +import { useNavigate } from "react-router"; +import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardStatusBucketRow } from "@/domain/dashboard/types/dashboard-stats"; +import { + workOrderDrilldownUrl, + workOrderStatusDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardBarList } from "./dashboard-bar-list"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No work orders match the current filters."; + +const DRILLABLE_STATUSES = new Set(ALL_WIZARD_STATUSES); + +interface StatusDistributionCardProps { + rows: DashboardStatusBucketRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +export function StatusDistributionCard({ + rows, + range, + isLoading = false, + error, +}: StatusDistributionCardProps) { + const navigate = useNavigate(); + + return ( + + ({ + id: row.status, + label: row.status, + count: row.count, + }))} + // Lifecycle labels the board can filter on are drillable; the "Unknown" + // bucket (unparseable statuses) has no board filter, so leave it inert. + isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)} + onSelectRow={(row) => + navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range))) + } + /> + + ); +} diff --git a/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts b/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts new file mode 100644 index 00000000..868b8d05 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils"; +import { useAuthContext } from "@/providers/auth-context"; +import { + DEFAULT_DASHBOARD_RANGE, + type DashboardRangeKey, +} from "@/domain/dashboard/types/dashboard-range"; +import { DASHBOARD_SCOPE_ALL, scopeDispatcherId, type DashboardScope } from "./dashboard-scope"; + +export interface DashboardScopeState { + canViewAll: boolean; + currentUserId: string | number | undefined; + range: DashboardRangeKey; + scope: DashboardScope; + /** The `dispatcherId` query param for the scoped endpoints (null = company-wide). */ + dispatcherId: string | null; + workloadPage: number; + performancePage: number; + showReset: boolean; + setWorkloadPage: (page: number) => void; + setPerformancePage: (page: number) => void; + changeRange: (next: DashboardRangeKey) => void; + changeScope: (next: DashboardScope) => void; + resetFilters: () => void; +} + +/** + * SH-336 dashboard scope/paging state. A Dispatcher (no + * `viewAllDispatchersOnDashboard`) is locked to their own work orders and sends + * no dispatcherId; Scheduler/Admin drive an explicit scope through the picker. + */ +export function useDashboardScope(): DashboardScopeState { + const { user } = useAuthContext(); + const canViewAll = canViewAllDispatchersOnDashboard(user?.userRoles); + + const [range, setRange] = useState(DEFAULT_DASHBOARD_RANGE); + const [scope, setScope] = useState(DASHBOARD_SCOPE_ALL); + const [workloadPage, setWorkloadPage] = useState(1); + const [performancePage, setPerformancePage] = useState(1); + + const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null; + + // A range or scope change re-scopes both dispatcher tables, so their page must + // reset — otherwise a switch while on page 3 lands on an out-of-range empty + // page that reads as a false "no dispatchers" state. + const resetPages = () => { + setWorkloadPage(1); + setPerformancePage(1); + }; + + const changeRange = (next: DashboardRangeKey) => { + setRange(next); + resetPages(); + }; + + const changeScope = (next: DashboardScope) => { + setScope(next); + resetPages(); + }; + + const resetFilters = () => { + setRange(DEFAULT_DASHBOARD_RANGE); + setScope(DASHBOARD_SCOPE_ALL); + resetPages(); + }; + + // A Dispatcher's forced own-WOs scope never triggers the reset affordance (only + // an active date range can, for that role); a Scheduler/Admin's non-All scope does. + const scopeIsFiltered = canViewAll && scope.mode !== "all"; + const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered; + + return { + canViewAll, + currentUserId: user?.id, + range, + scope, + dispatcherId, + workloadPage, + performancePage, + showReset, + setWorkloadPage, + setPerformancePage, + changeRange, + changeScope, + resetFilters, + }; +} diff --git a/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx b/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx new file mode 100644 index 00000000..d23776ff --- /dev/null +++ b/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx @@ -0,0 +1,64 @@ +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No active vendor has any work orders yet."; + +interface VendorInsightsCardProps { + rows: VendorInsightRow[]; + isLoading?: boolean; + error?: unknown; +} + +export function VendorInsightsCard({ rows, isLoading = false, error }: VendorInsightsCardProps) { + return ( + + + + + + Vendor + + Completion % + + + Reschedule % + + + Avg days + + + Jobs + + + + + {rows.map((row) => ( + + + {row.vendorName} + + {`${row.completionRate.toLocaleString()}%`} + {`${row.rescheduleRate.toLocaleString()}%`} + + {row.averageResolutionDays == null + ? "—" + : row.averageResolutionDays.toLocaleString()} + + {row.totalJobs.toLocaleString()} + + ))} + +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx b/src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx new file mode 100644 index 00000000..84f32923 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx @@ -0,0 +1,47 @@ +import { useNavigate } from "react-router"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardBreakdownRow } from "@/domain/dashboard/types/dashboard-stats"; +import { + workOrderDrilldownUrl, + workOrderTypeDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardBarList } from "./dashboard-bar-list"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No work orders match the current filters."; + +interface WorkOrderBreakdownCardProps { + breakdown: DashboardBreakdownRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +export function WorkOrderBreakdownCard({ + breakdown, + range, + isLoading = false, + error, +}: WorkOrderBreakdownCardProps) { + const navigate = useNavigate(); + + return ( + + ({ id: row.status, label: row.status, count: row.count }))} + isSelectable={(row) => workOrderTypeDrilldownSearch(row.id, range) !== null} + onSelectRow={(row) => { + const search = workOrderTypeDrilldownSearch(row.id, range); + if (search) navigate(workOrderDrilldownUrl(search)); + }} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx b/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx new file mode 100644 index 00000000..40c53e0d --- /dev/null +++ b/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { + UNMAPPED_REGION_LABEL, + type RegionWorkOrdersRow, +} from "@/domain/dashboard/types/dashboard-regions"; +import { + workOrderDrilldownUrl, + workOrderRegionDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardBarList } from "./dashboard-bar-list"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No work orders match the current filters."; + +interface WorkOrdersByRegionCardProps { + rows: RegionWorkOrdersRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +export function WorkOrdersByRegionCard({ + rows, + range, + isLoading = false, + error, +}: WorkOrdersByRegionCardProps) { + const navigate = useNavigate(); + + // SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet + // confirmed with the client ("do not implement until confirmed"), so only the + // four canonical zones are shown. + const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL); + + return ( + + ({ + id: row.region, + label: row.region, + count: row.workOrderCount, + }))} + onSelectRow={(row) => + navigate(workOrderDrilldownUrl(workOrderRegionDrilldownSearch(row.id, range))) + } + /> + + ); +} diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx new file mode 100644 index 00000000..60795d8f --- /dev/null +++ b/src/app/(protected)/dashboard/index.tsx @@ -0,0 +1,133 @@ +import { useNavigate } from "react-router"; +import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; +import PendingActionsIcon from "@mui/icons-material/PendingActions"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import { Box } from "@mui/material"; +import { PageHeader } from "@/components/ui/page-header"; +import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions"; +import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; +import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; +import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section"; +import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card"; +import { useDashboardScope } from "@/app/(protected)/dashboard/_components/use-dashboard-scope"; +import { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card"; +import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card"; +import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-card"; +import { useDashboardPerformance } from "@/domain/dashboard/use-cases/use-dashboard-performance"; +import { useDashboardRegions } from "@/domain/dashboard/use-cases/use-dashboard-regions"; +import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; +import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights"; +import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload"; +import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, + UPLIFTS_ROUTE, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; + +export default function DashboardPage() { + const navigate = useNavigate(); + const scope = useDashboardScope(); + const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope; + + const dispatcherUsers = useWorkOrderUsers(); + const stats = useDashboardStats(range, dispatcherId); + const workload = useDashboardWorkload(range, workloadPage, dispatcherId, canViewAll); + const performance = useDashboardPerformance(range, performancePage, dispatcherId, canViewAll); + const regions = useDashboardRegions(range, dispatcherId); + const vendors = useDashboardVendorInsights(); + + const isRefreshing = + [stats, workload, performance, regions, vendors].some((query) => query.isFetching) && + !stats.isLoading; + + return ( + + + } + /> + + } + tone="primary" + value={stats.data?.scheduledTomorrow} + loading={stats.isLoading} + active={(stats.data?.scheduledTomorrow ?? 0) > 0} + onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} + /> + } + tone="info" + value={stats.data?.pendingUplifts} + loading={stats.isLoading} + active={(stats.data?.pendingUplifts ?? 0) > 0} + onSelect={() => navigate(UPLIFTS_ROUTE)} + /> + } + tone="warning" + value={stats.data?.avetaPending} + loading={stats.isLoading} + active={(stats.data?.avetaPending ?? 0) > 0} + onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} + /> + + + + + + {canViewAll && ( + + )} + + + + + ); +} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx index e1af6bf9..b32a2d05 100644 --- a/src/app/(protected)/index.tsx +++ b/src/app/(protected)/index.tsx @@ -1,240 +1,5 @@ -import type { ReactNode } from "react"; -import { useNavigate } from "react-router"; -import AddIcon from "@mui/icons-material/Add"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined"; -import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined"; -import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; -import { - Alert, - Box, - CircularProgress, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; +import DashboardPage from "@/app/(protected)/dashboard"; -const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const; - -const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const; - -interface KpiCardProps { - title: string; - description?: string; - value?: string | number; - icon: ReactNode; - color: "success" | "primary" | "info" | "warning"; - onClick: () => void; - loading?: boolean; -} - -function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) { - const colorMap = { - success: "success.main", - primary: "primary.main", - info: "info.main", - warning: "warning.main", - } as const; - - return ( - - - - {icon} - - - - - {title} - - {value !== undefined && ( - - {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} - - )} - {Boolean(description) && ( - - {description} - - )} - - - - ); -} - -export default function DashboardPage() { - const navigate = useNavigate(); - const { data: stats, isLoading, isFetching, error } = useDashboardStats(); - - const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); - - return ( - - - - Dashboard - - {isFetching && !isLoading && } - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load dashboard stats"} - - )} - - } - color="success" - onClick={() => navigate("/workorders/new")} - /> - } - color="primary" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="info" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="warning" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - - - - - Priority Analysis - - - Age of open Work Orders by Priority - - - - - - - - Priority - - Total - - - 0-14 Days - - - 15-29 Days - - - 30-59 Days - - - 60+ Days - - - - - {PRIORITY_LEVELS.map((priority) => ( - - {priority} - — - — - — - — - — - - ))} - -
-
-
- - - - Invoice Analysis - - - - - - - - Status - - Count - - - Amount - - - - - {INVOICE_STATUSES.map((status) => ( - - {status} - 0 - $0.00 - - ))} - -
-
-
-
- ); +export default function HomePage() { + return ; } diff --git a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx index 8ee4ac73..ef9064df 100644 --- a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx +++ b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx @@ -29,158 +29,34 @@ import type { CreateTeamMemberInput, TeamMemberRole, } from "@/domain/team-members/types/team-member"; +import { + defaultsForRole, + getFormErrors, + PERMISSION_GROUPS, + PERMISSION_LABELS, + permissionOverrides, +} from "./team-member-permissions"; +import { TEAM_MEMBER_COLORS, TEAM_MEMBER_ROLES } from "./team-member-form-options"; -const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"]; -const COLORS = [ - "#F59E0B", - "#0D9488", - "#6366F1", - "#F43F5E", - "#7C3AED", - "#0EA5E9", - "#16A34A", - "#EA580C", - "#0891B2", - "#C026D3", -] as const; - -const PERMISSION_GROUPS = [ - { - label: "Team Members", - keys: ["manageTeamMembers", "changeTeamMemberRole"], - }, - { - label: "Vendors", - keys: ["createVendors", "editVendors", "deactivateVendors"], - }, - { - label: "Sites", - keys: ["createSites", "editSites", "deleteSites"], - }, - { - label: "Services", - keys: ["createServices", "editServices", "deactivateServices"], - }, - { - label: "Completion Document Templates", - keys: [ - "createCompletionDocTemplates", - "editCompletionDocTemplates", - "deleteCompletionDocTemplates", - ], - }, - { - label: "Work Orders", - keys: ["createWorkOrders", "editOthersWorkOrders", "cancelWorkOrders", "deleteWorkOrders"], - }, - { - label: "Uplifts", - keys: ["requestUplifts", "autoApproveUplifts", "reviewUplifts"], - }, - { - label: "Dashboard", - keys: ["viewAllDispatchersOnDashboard"], - }, -] as const; - -const PERMISSION_LABELS: Record = { - manageTeamMembers: "Manage team members", - changeTeamMemberRole: "Change team member role", - createVendors: "Create vendors", - editVendors: "Edit vendors", - deactivateVendors: "Deactivate vendors", - createSites: "Create sites", - editSites: "Edit sites", - deleteSites: "Delete sites", - createServices: "Create services", - editServices: "Edit services", - deactivateServices: "Deactivate services", - createCompletionDocTemplates: "Create completion document templates", - editCompletionDocTemplates: "Edit completion document templates", - deleteCompletionDocTemplates: "Delete completion document templates", - createWorkOrders: "Create work orders", - editOthersWorkOrders: "Edit other users' work orders", - cancelWorkOrders: "Cancel work orders", - deleteWorkOrders: "Delete work orders", - requestUplifts: "Request uplifts", - autoApproveUplifts: "Automatically approve uplifts", - reviewUplifts: "Review uplifts", - viewAllDispatchersOnDashboard: "View all Dispatchers on dashboard", -}; - -const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); -const DISPATCHER_DEFAULTS = new Set([ - "createVendors", - "editVendors", - "deactivateVendors", - "createSites", - "editSites", - "createWorkOrders", - "editOthersWorkOrders", - "cancelWorkOrders", - "requestUplifts", - "autoApproveUplifts", -]); -const SCHEDULER_DEFAULTS = new Set([ - ...Array.from(DISPATCHER_DEFAULTS).filter( - (key) => key !== "requestUplifts" && key !== "autoApproveUplifts", - ), - "deleteSites", - "createServices", - "editServices", - "createCompletionDocTemplates", - "editCompletionDocTemplates", - "viewAllDispatchersOnDashboard", -]); - -function defaultsForRole(role: TeamMemberRole): Set { - if (role === "Admin") return new Set(ALL_PERMISSION_KEYS); - return role === "Scheduler" ? new Set(SCHEDULER_DEFAULTS) : new Set(DISPATCHER_DEFAULTS); -} +const LEGACY_ROLE_OPTION = "__legacy-role__"; function emptyForm() { return { name: "", - role: "Dispatcher" as TeamMemberRole, + role: "Dispatcher" as TeamMemberRole | "", color: "", + colorNotice: "", email: "", phone: "", + active: true, serviceAreas: [] as string[], permissions: defaultsForRole("Dispatcher"), }; } -function permissionOverrides(role: TeamMemberRole, selected: Set) { - const defaults = defaultsForRole(role); - return Object.fromEntries( - ALL_PERMISSION_KEYS.filter((key) => defaults.has(key) !== selected.has(key)).map((key) => [ - key, - selected.has(key) ? 1 : 2, - ]), - ) as Record; -} - -type TeamMemberForm = ReturnType; +export type TeamMemberForm = ReturnType; type FormErrors = ReturnType; -function getFormErrors(form: TeamMemberForm, submitted: boolean, emailIsValid: boolean) { - return { - name: submitted && !form.name.trim() ? "Name is required." : "", - role: submitted && !form.role ? "Role is required." : "", - color: submitted && !form.color ? "Color is required." : "", - email: - submitted && !form.email.trim() - ? "Email is required." - : submitted && !emailIsValid - ? "Enter a valid email address." - : "", - serviceAreas: - submitted && form.role === "Dispatcher" && form.serviceAreas.length === 0 - ? "Select at least one service area." - : "", - }; -} - function PermissionsSection({ form, togglePermission, @@ -223,13 +99,62 @@ function PermissionsSection({ ); } -function TeamMemberFormFields({ +function RoleSelectField({ + role, + roleError, + updateRole, + disabled, + legacyRole, +}: { + role: TeamMemberRole | ""; + roleError: string; + updateRole: (role: TeamMemberRole) => void; + disabled: boolean; + legacyRole: string; +}) { + const legacyRolePending = Boolean(legacyRole) && !role; + + return ( + + Role + + {legacyRolePending && ( + + {legacyRole} is a legacy role and can no longer be saved. Choose Dispatcher, Scheduler, or + Admin. + + )} + {!legacyRolePending && Boolean(roleError) && {roleError}} + + ); +} + +export function TeamMemberFormFields({ form, setForm, errors, updateRole, toggleArea, togglePermission, + roleDisabled = false, + legacyRole = "", }: { form: TeamMemberForm; setForm: Dispatch>; @@ -237,6 +162,8 @@ function TeamMemberFormFields({ updateRole: (role: TeamMemberRole) => void; toggleArea: (area: string) => void; togglePermission: (key: string) => void; + roleDisabled?: boolean; + legacyRole?: string; }) { return ( @@ -250,35 +177,26 @@ function TeamMemberFormFields({ required fullWidth /> - - Role - - {Boolean(errors.role) && {errors.role}} - + Color - {COLORS.map((color) => ( + {TEAM_MEMBER_COLORS.map((color) => ( + + + + ) : ( + + + + )} +
+ ); +} + +function useTeamMemberEditor( + currentMember: TeamMemberDetail | undefined, + memberId: string | number | undefined, +) { + const [editing, setEditing] = useState(false); + const [form, setForm] = useState(emptyForm); + const [submitted, setSubmitted] = useState(false); + const updateTeamMember = useUpdateTeamMember(); + const currentMemberRef = useRef(currentMember); + + useEffect(() => { + currentMemberRef.current = currentMember; + }, [currentMember]); + + useEffect(() => { + const member = currentMemberRef.current; + if (member) { + setForm(formFromMember(member)); + setEditing(false); + setSubmitted(false); + } + }, [currentMember?.id]); + + useEffect(() => { + setEditing(false); + setSubmitted(false); + }, [memberId]); + + const emailIsValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim()); + const errors = useMemo( + () => getFormErrors(form, submitted, emailIsValid), + [emailIsValid, form, submitted], + ); + const isValid = + Boolean(form.name.trim()) && + Boolean(form.role) && + Boolean(form.color) && + emailIsValid && + (form.role !== "Dispatcher" || form.serviceAreas.length > 0); + const protectedOwner = currentMember?.isAccountOwner === true; + const legacyRole = currentMember && !canonicalRole(currentMember.role) ? currentMember.role : ""; + + const updateRole = (role: TeamMemberRole) => { + setForm((current) => ({ + ...current, + role, + serviceAreas: [], + permissions: defaultsForRole(role), + })); + }; + + const toggleArea = (area: string) => { + setForm((current) => ({ + ...current, + serviceAreas: current.serviceAreas.includes(area) + ? current.serviceAreas.filter((value) => value !== area) + : [...current.serviceAreas, area], + })); + }; + + const togglePermission = (key: string) => { + setForm((current) => { + const permissions = new Set(current.permissions); + if (permissions.has(key)) permissions.delete(key); + else permissions.add(key); + return { ...current, permissions }; + }); + }; + + const save = () => { + setSubmitted(true); + if (!isValid || !currentMember || form.role === "") return; + + updateTeamMember.mutate( + { + id: currentMember.id, + input: { + name: form.name.trim(), + role: form.role, + color: form.color, + email: form.email.trim(), + phone: form.phone.trim() || undefined, + serviceAreas: form.serviceAreas, + permissionOverrides: permissionOverrides(form.role, form.permissions), + isActive: form.active, + }, + }, + { onSuccess: () => setEditing(false) }, + ); + }; + + return { + editing, + form, + setForm, + errors, + protectedOwner, + legacyRole, + isPending: updateTeamMember.isPending, + updateRole, + toggleArea, + togglePermission, + save, + cancel: () => { + if (currentMember) setForm(formFromMember(currentMember)); + setSubmitted(false); + setEditing(false); + }, + beginEdit: () => setEditing(true), + }; +} + +function DrawerHeader({ + member, + currentMember, + onClose, +}: { + member: TeamMemberListItem | null; + currentMember: TeamMemberDetail | undefined; + onClose: () => void; +}) { + return ( + <> + + + + {currentMember?.name ?? member?.name ?? "Team Member"} + + + {currentMember?.role ?? member?.role ?? ""} + + + + + + + + + ); +} + +function DrawerContent({ + detail, + currentMember, + editing, + editor, +}: { + detail: ReturnType; + currentMember: TeamMemberDetail | undefined; + editing: boolean; + editor: ReturnType; +}) { + return ( + + {Boolean(detail.isLoading) && } + {detail.error !== null && detail.error !== undefined && ( + {detail.error.message} + )} + {currentMember !== undefined && !editing && } + {currentMember !== undefined && editing && ( + + )} + + ); +} + +export function TeamMemberDetailDrawer({ + member, + onClose, +}: { + member: TeamMemberListItem | null; + onClose: () => void; +}) { + const detail = useTeamMemberDetail(member?.id ?? null); + const currentMember = detail.data; + const editor = useTeamMemberEditor(currentMember, member?.id); + + return ( + + + + + + {currentMember !== undefined && ( + + )} + + + ); +} diff --git a/src/app/(protected)/team-members/_components/team-member-form-options.ts b/src/app/(protected)/team-members/_components/team-member-form-options.ts new file mode 100644 index 00000000..3f220a37 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-member-form-options.ts @@ -0,0 +1,78 @@ +import type { TeamMemberRole } from "@/domain/team-members/types/team-member"; + +export const TEAM_MEMBER_ROLES: readonly TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"]; + +export const TEAM_MEMBER_COLORS = [ + "#F59E0B", + "#0D9488", + "#6366F1", + "#F43F5E", + "#7C3AED", + "#0EA5E9", + "#16A34A", + "#EA580C", + "#0891B2", + "#C026D3", +] as const; + +/** Returns the supported role matching the stored value (case-insensitive), or null for legacy roles. */ +export function canonicalRole(value: string): TeamMemberRole | null { + const trimmed = value.trim(); + return TEAM_MEMBER_ROLES.find((role) => role.toLowerCase() === trimmed.toLowerCase()) ?? null; +} + +function parseHexColor(value: string): [number, number, number] | null { + const match = /^#?([0-9a-f]{6})$/i.exec(value.trim()); + if (!match) return null; + const numeric = Number.parseInt(match[1], 16); + return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255]; +} + +/** Returns the palette color closest to the stored color by RGB distance, or the first palette color when unparseable. */ +export function nearestPaletteColor(value: string): string { + const target = parseHexColor(value); + if (!target) return TEAM_MEMBER_COLORS[0]; + + let nearest: string = TEAM_MEMBER_COLORS[0]; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const color of TEAM_MEMBER_COLORS) { + const [red, green, blue] = parseHexColor(color) ?? [0, 0, 0]; + const distance = (red - target[0]) ** 2 + (green - target[1]) ** 2 + (blue - target[2]) ** 2; + if (distance < nearestDistance) { + nearest = color; + nearestDistance = distance; + } + } + return nearest; +} + +export interface ResolvedFormColor { + color: string; + notice: string; +} + +/** + * Maps a stored color onto the current palette. Palette colors (any casing) are + * canonicalized; legacy colors are replaced by the nearest palette color with a + * notice so the replacement is never silent. + */ +export function resolveFormColor(stored: string): ResolvedFormColor { + const trimmed = stored.trim(); + if (!trimmed) { + return { + color: TEAM_MEMBER_COLORS[0], + notice: `No color was saved for this member; preselected ${TEAM_MEMBER_COLORS[0]} from the palette.`, + }; + } + + const canonical = TEAM_MEMBER_COLORS.find( + (color) => color.toLowerCase() === trimmed.toLowerCase(), + ); + if (canonical) return { color: canonical, notice: "" }; + + const nearest = nearestPaletteColor(trimmed); + return { + color: nearest, + notice: `Saved color ${trimmed} is not in the current palette and was replaced with the nearest palette color ${nearest}.`, + }; +} diff --git a/src/app/(protected)/team-members/_components/team-member-permissions.ts b/src/app/(protected)/team-members/_components/team-member-permissions.ts new file mode 100644 index 00000000..bd1097f5 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-member-permissions.ts @@ -0,0 +1,120 @@ +import type { TeamMemberRole } from "@/domain/team-members/types/team-member"; + +export const PERMISSION_GROUPS = [ + { label: "Team Members", keys: ["manageTeamMembers", "changeTeamMemberRole"] }, + { label: "Vendors", keys: ["createVendors", "editVendors", "deactivateVendors"] }, + { label: "Sites", keys: ["createSites", "editSites", "deleteSites"] }, + { label: "Services", keys: ["createServices", "editServices", "deactivateServices"] }, + { + label: "Completion Document Templates", + keys: [ + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", + ], + }, + { + label: "Work Orders", + keys: ["createWorkOrders", "editOthersWorkOrders", "cancelWorkOrders", "deleteWorkOrders"], + }, + { label: "Uplifts", keys: ["requestUplifts", "autoApproveUplifts", "reviewUplifts"] }, + { label: "Dashboard", keys: ["viewAllDispatchersOnDashboard"] }, +] as const; + +export const PERMISSION_LABELS: Record = { + manageTeamMembers: "Manage team members", + changeTeamMemberRole: "Change team member role", + createVendors: "Create vendors", + editVendors: "Edit vendors", + deactivateVendors: "Deactivate vendors", + createSites: "Create sites", + editSites: "Edit sites", + deleteSites: "Delete sites", + createServices: "Create services", + editServices: "Edit services", + deactivateServices: "Deactivate services", + createCompletionDocTemplates: "Create completion document templates", + editCompletionDocTemplates: "Edit completion document templates", + deleteCompletionDocTemplates: "Delete completion document templates", + createWorkOrders: "Create work orders", + editOthersWorkOrders: "Edit other users' work orders", + cancelWorkOrders: "Cancel work orders", + deleteWorkOrders: "Delete work orders", + requestUplifts: "Request uplifts", + autoApproveUplifts: "Automatically approve uplifts", + reviewUplifts: "Review uplifts", + viewAllDispatchersOnDashboard: "View all Dispatchers on dashboard", +}; + +export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); +const DISPATCHER_DEFAULTS = new Set([ + "createVendors", + "editVendors", + "deactivateVendors", + "createSites", + "editSites", + "createWorkOrders", + "editOthersWorkOrders", + "cancelWorkOrders", + "requestUplifts", + "autoApproveUplifts", +]); +const SCHEDULER_DEFAULTS = new Set([ + ...Array.from(DISPATCHER_DEFAULTS).filter( + (key) => key !== "requestUplifts" && key !== "autoApproveUplifts", + ), + "deleteSites", + "createServices", + "editServices", + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "viewAllDispatchersOnDashboard", +]); + +export function defaultsForRole(role: TeamMemberRole): Set { + if (role === "Admin") return new Set(ALL_PERMISSION_KEYS); + return role === "Scheduler" ? new Set(SCHEDULER_DEFAULTS) : new Set(DISPATCHER_DEFAULTS); +} + +export function permissionOverrides(role: TeamMemberRole, selected: Set) { + const defaults = defaultsForRole(role); + return Object.fromEntries( + ALL_PERMISSION_KEYS.filter((key) => defaults.has(key) !== selected.has(key)).map((key) => [ + key, + selected.has(key) ? 1 : 2, + ]), + ) as Record; +} + +export interface TeamMemberFormValues { + name: string; + role: TeamMemberRole | ""; + color: string; + email: string; + phone: string; + active: boolean; + serviceAreas: string[]; + permissions: Set; +} + +export function getFormErrors( + form: TeamMemberFormValues, + submitted: boolean, + emailIsValid: boolean, +) { + return { + name: submitted && !form.name.trim() ? "Name is required." : "", + role: submitted && !form.role ? "Role is required." : "", + color: submitted && !form.color ? "Color is required." : "", + email: + submitted && !form.email.trim() + ? "Email is required." + : submitted && !emailIsValid + ? "Enter a valid email address." + : "", + serviceAreas: + submitted && form.role === "Dispatcher" && form.serviceAreas.length === 0 + ? "Select at least one service area." + : "", + }; +} diff --git a/src/app/(protected)/team-members/index.tsx b/src/app/(protected)/team-members/index.tsx index 61990aa7..02e9c2ab 100644 --- a/src/app/(protected)/team-members/index.tsx +++ b/src/app/(protected)/team-members/index.tsx @@ -2,23 +2,10 @@ import { useState } from "react"; import { useDebounce } from "@/hooks/use-debounce"; import { useTeamMembersList } from "@/domain/team-members/use-cases/use-team-members-list"; import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; -import { - Alert, - Avatar, - Box, - Button, - Chip, - Drawer, - IconButton, - Stack, - Tab, - Tabs, - TextField, - Typography, -} from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; +import { Alert, Box, Button, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; import { TeamMembersTable } from "./_components/team-members-table"; import { AddTeamMemberDialog } from "./_components/add-team-member-dialog"; +import { TeamMemberDetailDrawer } from "./_components/team-member-detail-drawer"; type StatusTab = "active" | "inactive"; @@ -27,57 +14,6 @@ const STATUS_LABELS: Record = { inactive: "Inactive", }; -function MemberStatus({ status }: { status: TeamMemberListItem["status"] }) { - return ; -} - -function TeamMemberDrawer({ - member, - onClose, -}: { - member: TeamMemberListItem | null; - onClose: () => void; -}) { - return ( - - - - Team Member - - - - - {member !== null && ( - - - - {member.name.slice(0, 1).toUpperCase() || "?"} - - - {member.name || "—"} - {member.role || "—"} - - - - - Service Area: {member.serviceArea || "—"} - - - Phone: {member.phone || "—"} - - - Email: {member.email || "—"} - - - )} - - - ); -} - export default function TeamMembersListPage() { const [search, setSearch] = useState(""); const [tab, setTab] = useState("active"); @@ -177,7 +113,7 @@ export default function TeamMembersListPage() { - setSelectedMember(null)} /> + setSelectedMember(null)} /> setAddMemberOpen(false)} />
); diff --git a/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts b/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts new file mode 100644 index 00000000..2654fedd --- /dev/null +++ b/src/app/(protected)/uplifts/_components/open-uplift-evidence.ts @@ -0,0 +1,13 @@ +import { toast } from "react-toastify"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; + +export function openUpliftEvidence(row: UpliftQueueItem): void { + void upliftsApi + .downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence") + .catch((error: unknown) => { + toast.error( + error instanceof Error ? error.message : "Unable to download evidence right now.", + ); + }); +} diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx new file mode 100644 index 00000000..fb40d7c1 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -0,0 +1,371 @@ +import { + Box, + Button, + Chip, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { Text } from "@/components/ui/text"; +import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; + +export type UpliftApprovalTab = "pending" | "approved"; + +const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; +const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; + +function workOrderLabel(row: UpliftQueueItem): string { + return row.woNumber || row.dispatchNumber || "—"; +} + +function WaitingCell({ row }: { row: UpliftQueueItem }) { + if (!row.requestedAt) { + return ( + + — + + ); + } + const color = waitTimeColor(row.requestedAt); + const className = getWaitTimeTextClass(color); + return ( + + {timeSince(row.requestedAt)} + + ); +} + +function AttachmentsCell({ + row, + onOpenAttachment, +}: { + row: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { + if (row.evidenceDocumentId == null && !row.evidenceFileName) { + return ( + + — + + ); + } + const extraCount = + typeof row.attachmentCount === "number" && row.attachmentCount > 1 + ? row.attachmentCount - 1 + : 0; + return ( + + onOpenAttachment(row) : undefined} + clickable={row.evidenceDocumentId != null} + /> + {extraCount > 0 && } + + ); +} + +function PendingRowActions({ + row, + onApprove, + onReject, + isDecisionPending, +}: { + row: UpliftQueueItem; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + const canApproveTier = useUpliftsCanApprove(row.requiredTier); + const canDecide = row.canDecide && canApproveTier.data === true; + const tooltip = canDecide ? "" : `Requires Tier ${row.requiredTier} role`; + return ( + e.stopPropagation()}> + + + + + + + + + + + + ); +} + +function ApprovedRowActions({ + row, + canRevoke, + onRevoke, + isDecisionPending, +}: { + row: UpliftQueueItem; + canRevoke: boolean; + onRevoke: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + const closed = row.workOrderClosed === true; + const tooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : ""; + return ( + e.stopPropagation()}> + + + + + + + ); +} + +function UpliftApprovalRow({ + row, + tab, + canRevoke, + onOpenDetail, + onApprove, + onReject, + onRevoke, + onOpenAttachment, + isDecisionPending, +}: { + row: UpliftQueueItem; + tab: UpliftApprovalTab; + canRevoke: boolean; + onOpenDetail: (row: UpliftQueueItem) => void; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + return ( + onOpenDetail(row)} + onKeyDown={(e) => { + if (e.key === "Enter") onOpenDetail(row); + }} + tabIndex={0} + aria-label={`Open uplift details for ${workOrderLabel(row)}`} + > + + + + {workOrderLabel(row)} + + {Boolean(row.site) && ( + + {row.site} + + )} + + + + + {Boolean(row.serviceName) && ( + + {row.serviceName} + + )} + + {row.vendorCompanyName || "—"} + + + + + + {formatUpliftMoney(row.requestedNTE)} + + + + {row.approvedOnWoTotal != null ? ( + {formatUpliftMoney(row.approvedOnWoTotal)} + ) : ( + + — + + )} + + + + {row.vendorReason} + + + + + + {row.requestedByVendorName || "—"} + {tab === "pending" ? ( + + + + ) : ( + <> + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + )} + + {tab === "pending" ? ( + + ) : ( + + )} + + + ); +} + +const PENDING_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Waiting", + "Actions", +] as const; + +const APPROVED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Approved By", + "Approved At", + "Actions", +] as const; + +export function UpliftApprovalsTable({ + tab, + isLoading, + rows, + canRevoke, + onOpenDetail, + onApprove, + onReject, + onRevoke, + onOpenAttachment, + isDecisionPending, +}: { + tab: UpliftApprovalTab; + isLoading: boolean; + rows: UpliftQueueItem[]; + canRevoke: boolean; + onOpenDetail: (row: UpliftQueueItem) => void; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; + isDecisionPending: boolean; +}) { + const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; + return ( + + + + + {headerCells.map((label) => ( + + {label} + + ))} + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + {tab === "pending" ? ( + + No uplifts waiting for approval. + + ) : ( + + No approved uplifts yet + + Uplifts you approve are archived here for reference. + + + )} + + + ) : ( + rows.map((row) => ( + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/uplifts/_components/uplift-decision-dialogs.tsx b/src/app/(protected)/uplifts/_components/uplift-decision-dialogs.tsx new file mode 100644 index 00000000..7cc8537b --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-decision-dialogs.tsx @@ -0,0 +1,42 @@ +import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog"; +import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog"; +import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; + +export type UpliftApprovalController = ReturnType; + +function woFallbackNumber(target: UpliftApprovalController["rejectTarget"]): string { + return target?.woNumber || String(target?.workOrderId ?? ""); +} + +export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprovalController }) { + const { + rejectTarget, + revokeTarget, + rejectPending, + revokePending, + handleRejectConfirm, + handleRevokeConfirm, + closeReject, + closeRevoke, + } = controller; + return ( + <> + + + + ); +} diff --git a/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx new file mode 100644 index 00000000..55422e2e --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-detail-modal.tsx @@ -0,0 +1,418 @@ +import { useMemo, type ReactNode } from "react"; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tooltip, +} from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { Text } from "@/components/ui/text"; +import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts"; +import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; +import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; + +const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; +const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; +const UNAVAILABLE_LABEL = "Unavailable"; + +type ModalCallbacks = { + onClose: () => void; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; + onOpenAttachment: (row: UpliftQueueItem) => void; +}; + +function DetailField({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function UpliftAttachments({ + item, + onOpenAttachment, +}: { + item: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { + if (item.evidenceDocumentId == null && !item.evidenceFileName) { + return ( + + No attachments + + ); + } + const extraCount = + typeof item.attachmentCount === "number" && item.attachmentCount > 1 + ? item.attachmentCount - 1 + : 0; + return ( + + onOpenAttachment(item) : undefined} + clickable={item.evidenceDocumentId != null} + /> + {extraCount > 0 && } + + ); +} + +function WorkOrderSection({ + item, + info, + unavailable, +}: { + item: UpliftQueueItem; + info?: WorkOrderTableRow; + unavailable: boolean; +}) { + const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—"; + return ( + + + {item.woNumber || "—"} + + + {info?.site || item.site || "—"} + + + {info?.status || statusFallback} + + + + {info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")} + + + + {item.vendorCompanyName || "—"} + + + ); +} + +function RequestSection({ + item, + onOpenAttachment, +}: { + item: UpliftQueueItem; + onOpenAttachment: (row: UpliftQueueItem) => void; +}) { + const waitingClass = item.requestedAt + ? getWaitTimeTextClass(waitTimeColor(item.requestedAt)) + : undefined; + return ( + + + + {formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)} + + + + {item.requestedByVendorName || "—"} + + + + {item.requestedAt ? timeSince(item.requestedAt) : "—"} + + + + {item.vendorReason || "No justification provided."} + + + + + + ); +} + +function ApprovedOnWoBreakdown({ + auto, + admin, + total, + unavailable, +}: { + auto: number | null; + admin: number | null; + total: number | null; + unavailable: boolean; +}) { + const rows: Array<{ label: string; value: number | null; strong: boolean }> = [ + { label: "Auto-approved", value: auto, strong: false }, + { label: "Admin-approved", value: admin, strong: false }, + { label: "Total", value: total, strong: true }, + ]; + return ( + + {rows.map((row) => ( + + ))} + + ); +} + +function BreakdownRow({ + label, + value, + strong, + unavailable, +}: { + label: string; + value: number | null; + strong: boolean; + unavailable: boolean; +}) { + return ( + + + {label} + + + {value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"} + + + ); +} + +function PendingModalActions({ + item, + approvePending, + onApprove, + onReject, +}: { + item: UpliftQueueItem; + approvePending: boolean; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; +}) { + const canApproveTier = useUpliftsCanApprove(item.requiredTier); + const canDecide = item.canDecide && canApproveTier.data === true; + const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`; + return ( + <> + + + + + + + + + + + + ); +} + +function UpliftModalActions({ + item, + closed, + canRevoke, + approvePending, + revokePending, + onApprove, + onReject, + onRevoke, +}: { + item: UpliftQueueItem; + closed: boolean; + canRevoke: boolean; + approvePending: boolean; + revokePending: boolean; + onApprove: (row: UpliftQueueItem) => void; + onReject: (row: UpliftQueueItem) => void; + onRevoke: (row: UpliftQueueItem) => void; +}) { + const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : ""; + return ( + <> + {item.status === "Pending" && ( + + )} + {item.status === "Approved" && ( + + + + + + )} + + ); +} + +export function UpliftDetailModal({ + item, + canRevoke, + approvePending, + revokePending, + onClose, + onApprove, + onReject, + onRevoke, + onOpenAttachment, +}: { + item: UpliftQueueItem | null; + canRevoke: boolean; + approvePending: boolean; + revokePending: boolean; +} & ModalCallbacks) { + const open = item != null; + // Permission failures (e.g. 403 for account-scoped users) are handled quietly + // in this modal: dependent sections fall back to "Unavailable" instead of toasting. + const quietMeta = { suppressErrorToast: true } as const; + const boardDetail = useWorkOrderBoardDetail( + item != null && item.workOrderId != null ? item.workOrderId : undefined, + true, + quietMeta, + ); + const woUpliftsQuery = useWorkOrderUplifts( + item != null && item.workOrderId != null ? item.workOrderId : null, + quietMeta, + ); + + const info = boardDetail.data?.info; + const closed = useMemo(() => { + if (item?.workOrderClosed === true) return true; + return info ? isWorkOrderUpliftsReadOnly(info.status) : false; + }, [item?.workOrderClosed, info]); + + const breakdown = useMemo(() => { + const woUplifts = woUpliftsQuery.data ?? []; + const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved"); + const adminFallback = sumUpliftAmounts(woUplifts, "approved"); + const hasWoData = woUplifts.length > 0; + return { + auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null), + admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null), + total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null), + }; + }, [ + item?.approvedOnWoAuto, + item?.approvedOnWoAdmin, + item?.approvedOnWoTotal, + woUpliftsQuery.data, + ]); + + if (!open) { + return null; + } + + return ( + + + + Uplift request + + + {item.woNumber || item.dispatchNumber || "Work order"} · {item.status} + + + + + + + + + + + + + + + + + ); +} + +function sumUpliftAmounts( + uplifts: ReadonlyArray<{ status: string; amount: number }>, + status: string, +): number { + return uplifts + .filter((uplift) => uplift.status === status) + .reduce((sum, uplift) => sum + uplift.amount, 0); +} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index 382214d2..eb891ab7 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,473 +1,190 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { useState } from "react"; +import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, - TextField, - Tooltip, - Typography, -} from "@mui/material"; -import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; -import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; -import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift"; -import { - useApproveUplift, - useRejectUplift, - useRequestChangesUplift, -} from "@/domain/uplifts/use-cases/use-uplift-actions"; + UpliftApprovalsTable, + type UpliftApprovalTab, +} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; +import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; +import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; +import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { isAdminUser } from "@/lib/auth/user-utils"; +import { useAuthContext } from "@/providers/auth-context"; +import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; -import { formatDateTime } from "@/lib/time-utils"; -import { toast } from "react-toastify"; -import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog"; +import { Text } from "@/components/ui/text"; -const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES]; +const PAGE_SIZE = 25; -type UpliftActionType = "approve" | "reject" | "request-changes"; - -type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem }; - -function formatMoney(value: number | null | undefined): string { - return `$${Number(value ?? 0).toFixed(2)}`; -} - -function formatBytes(value: number | null | undefined): string { - if (value == null) return ""; - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`; - return `${(value / (1024 * 1024)).toFixed(1)} MB`; -} - -function UpliftQueueHeader({ - totalCount, - isFetching, - isLoading, -}: { - totalCount: number; - isFetching: boolean; - isLoading: boolean; -}) { +function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: number | null }) { return ( - - - Uplift Queue - - - {totalCount} total - - {isFetching && !isLoading && } - + + + Uplift Approvals + + + Requests to increase the approved spend on a work order, above its remaining auto-approval + allowance. + + + Pending exposure:{" "} + {pendingExposureTotal && pendingExposureTotal > 0 + ? formatUpliftMoney(pendingExposureTotal) + : "—"} + + ); } -function UpliftQueueFilters({ - status, - tier, - onStatusChange, - onTierChange, +function UpliftApprovalsTabs({ + tab, + pendingCount, + onChange, }: { - status: string; - tier: string; - onStatusChange: (value: string) => void; - onTierChange: (value: string) => void; + tab: UpliftApprovalTab; + pendingCount: number; + onChange: (_event: unknown, value: string) => void; }) { return ( - - - Status - - - - Tier - - - - ); -} - -function UpliftQueueRow({ - row, - onViewWorkOrder, - onDownloadEvidence, - onDecide, -}: { - row: UpliftQueueItem; - onViewWorkOrder: (row: UpliftQueueItem) => void; - onDownloadEvidence: (row: UpliftQueueItem) => void; - onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void; -}) { - const decisionable = row.status === "Pending"; - return ( - - {row.dispatchNumber} - {row.poNumber} - {row.vendorCompanyName} - {formatMoney(row.currentNTE)} - {formatMoney(row.requestedNTE)} - - - - - - - - {row.evidenceDocumentId != null ? ( - - {row.evidenceFileName} - {Boolean(row.evidenceContentType) && ( - - {row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)} - - )} - + + + Pending + - ) : ( - - — - - )} - - {row.requestedAt ? formatDateTime(row.requestedAt) : ""} - - - {Boolean(row.workOrderId) && ( - - )} - {decisionable && ( - <> - - - - - - - - - - - - - - - - - )} - - - + } + /> + + ); } -function UpliftQueueTable({ - isLoading, - rows, - onViewWorkOrder, - onDownloadEvidence, - onDecide, +function UpliftApprovalsPagination({ + tab, + count, + pendingPage, + approvedPage, + onPendingPageChange, + onApprovedPageChange, }: { - isLoading: boolean; - rows: UpliftQueueItem[]; - onViewWorkOrder: (row: UpliftQueueItem) => void; - onDownloadEvidence: (row: UpliftQueueItem) => void; - onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void; + tab: UpliftApprovalTab; + count: number; + pendingPage: number; + approvedPage: number; + onPendingPageChange: (page: number) => void; + onApprovedPageChange: (page: number) => void; }) { return ( - - - - - Dispatch - PO - Vendor - Current NTE - Requested - Tier - Status - Evidence - Requested At - Actions - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No uplift requests match these filters. - - - - ) : ( - rows.map((row) => ( - - )) - )} - -
-
+ + tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) + } + rowsPerPage={PAGE_SIZE} + rowsPerPageOptions={[PAGE_SIZE]} + /> ); } -function UpliftDecisionDialog({ - action, - note, - noteRequired, - decisionPending, - onNoteChange, - onClose, - onConfirm, -}: { - action: UpliftDecision | null; - note: string; - noteRequired: boolean; - decisionPending: boolean; - onNoteChange: (value: string) => void; - onClose: () => void; - onConfirm: () => void; -}) { +function UpliftQueueError({ error }: { error: unknown }) { + if (!error) return null; return ( - - - {action?.type === "approve" - ? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?` - : "Request Changes"} - - - {action?.type === "approve" && ( - - {formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)} - - )} - onNoteChange(e.target.value)} - /> - - - - - - + + {error instanceof Error ? error.message : "Failed to load uplift queue"} + ); } export default function UpliftQueuePage() { - const navigate = useNavigate(); - const [status, setStatus] = useState("Pending"); - const [tier, setTier] = useState(""); - const [page, setPage] = useState(0); - const [pageSize] = useState(25); - const [upliftAction, setUpliftAction] = useState(null); - const [note, setNote] = useState(""); + const [tab, setTab] = useState("pending"); + const [pendingPage, setPendingPage] = useState(0); + const [approvedPage, setApprovedPage] = useState(0); - const listParams = useMemo( - () => ({ status, tier, page: page + 1, pageSize }), - [status, tier, page, pageSize], - ); + const { user } = useAuthContext(); + const canRevokeUplifts = isAdminUser(user?.userRoles); - const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); - const approveUplift = useApproveUplift(); - const rejectUplift = useRejectUplift(); - const requestChangesUplift = useRequestChangesUplift(); + const pendingQuery = useUpliftsQueue({ + status: "Pending", + page: pendingPage + 1, + pageSize: PAGE_SIZE, + }); + const approvedQuery = useUpliftsQueue({ + status: "Approved", + page: approvedPage + 1, + pageSize: PAGE_SIZE, + }); - const rows = data?.items ?? []; - const totalCount = data?.totalCount ?? 0; - const genericAction = upliftAction?.type === "reject" ? null : upliftAction; - const noteRequired = genericAction?.type === "request-changes"; - const decisionPending = approveUplift.isPending || requestChangesUplift.isPending; + const controller = useUpliftApprovalController(); + const { + detailItem, + approvePending, + rejectPending, + revokePending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + closeDetail, + openDetail, + } = controller; - const handleStatusChange = (value: string) => { - setStatus(value); - setPage(0); - }; - const handleTierChange = (value: string) => { - setTier(value); - setPage(0); - }; + const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; + const rows = activeQuery.data?.items ?? []; + const pendingCount = pendingQuery.data?.totalCount ?? 0; + const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; - const closeDialog = () => { - setUpliftAction(null); - setNote(""); - }; - - const handleConfirm = () => { - if (!upliftAction) return; - if (noteRequired && !note.trim()) return; - const id = upliftAction.item.id; - const onDone = () => { - closeDialog(); - void refetch(); - }; - if (upliftAction.type === "approve") { - approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone }); - return; + const handleTabChange = (_event: unknown, value: string) => { + if (value === "pending" || value === "approved") { + setTab(value); } - if (upliftAction.type === "request-changes") { - requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone }); - } - }; - - const handleRejectConfirm = (reason: string) => { - if (upliftAction?.type !== "reject") return; - rejectUplift.mutate( - { id: upliftAction.item.id, note: reason }, - { - onSuccess: () => { - closeDialog(); - void refetch(); - }, - }, - ); - }; - - const downloadEvidence = async (item: UpliftQueueItem) => { - try { - await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Unable to download evidence."); - } - }; - - const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => { - setUpliftAction({ type, item }); - setNote(""); }; return ( - - - - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load uplift queue"} - - )} - + + + navigate(`/workorders/${r.workOrderId}`)} - onDownloadEvidence={(r) => void downloadEvidence(r)} - onDecide={handleDecide} + canRevoke={canRevokeUplifts} + onOpenDetail={openDetail} + onApprove={handleApprove} + onReject={handleRejectRequest} + onRevoke={handleRevokeRequest} + onOpenAttachment={openUpliftEvidence} + isDecisionPending={approvePending || rejectPending || revokePending} /> - setPage(nextPage)} - rowsPerPage={pageSize} - rowsPerPageOptions={[25]} - /> - - + {detailItem != null && ( + + )} + ); } diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts index 0a5f394b..17069cc9 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts @@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft"; import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions"; import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; +import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes"; import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type"; type UseNewWoWizardControllerArgs = { @@ -49,6 +50,12 @@ export function useNewWoWizardController({ draftState.setDraft, ); + const { saveCompanyNotes } = useWizardVendorCompanyNotes( + open, + draftState.draft.vendorId, + draftState.setDraft, + ); + const patchDraft = (patch: Partial) => { draftState.patchDraft(patch); if (patch.locationId !== undefined) { @@ -56,7 +63,15 @@ export function useNewWoWizardController({ } }; - const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + const createMutation = useCreateWorkOrderFromWizard((createdDraft) => { + // Close the dialog first: the work-order create's isPending has already dropped, so + // holding the dialog open through the notes save would re-enable Create and unlock + // pointer events, and a second click would create a second work order. The save runs + // independently of the dialog — fetchCurrentBaseline covers a missing baseline and the + // note-save failure toast is surfaced from the mutation itself, not the dialog. + onOpenChange(false); + void saveCompanyNotes(createdDraft); + }); const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } = useWizardDuplicateActions({ open, diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts new file mode 100644 index 00000000..73625b6a --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes.ts @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; +import { + NOTES_SAVE_FAILED_MESSAGE, + useSaveVendorCompanyNotes, +} from "@/domain/vendors/use-cases/use-save-vendor-company-notes"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { + buildCompanyNotesUpdate, + type VendorCompanyNotesBaseline, +} from "@/domain/vendors/utils/build-company-notes-patch"; + +type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void; + +type VendorNotesBaseline = VendorCompanyNotesBaseline & { vendorId: string }; + +/** + * The wizard's Vendor Notes field is the vendor company's notes (same field as the Vendors + * page). Picking a technician pre-fills the company's current notes once per company, so + * switching technicians within the same company keeps the dispatcher's edits. Notes typed + * for the current selection always win over roster data that arrives late, and a save whose + * baseline is missing or belongs to another selection reads the current roster before + * building the PATCH. + */ +export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, setDraft: SetDraft) { + const queryClient = useQueryClient(); + const { data: roster } = useVendorCompanyRoster(vendorId || undefined); + const saveNotes = useSaveVendorCompanyNotes(); + const [baseline, setBaseline] = useState(null); + + useEffect(() => { + if (open) { + setBaseline(null); + } + }, [open]); + + useEffect(() => { + if (!vendorId) { + // Cleared technician: the next pick re-seeds even for the same company. + if (baseline) setBaseline(null); + return; + } + if (!roster || roster.companyId === null) { + return; + } + if ( + baseline && + baseline.vendorId === vendorId && + String(baseline.companyId) === String(roster.companyId) + ) { + return; + } + setBaseline({ + vendorId, + companyId: roster.companyId, + rowVersion: roster.rowVersion, + notes: roster.notes, + }); + setDraft((current) => { + if (current.vendorId !== vendorId) { + return current; + } + // Seed only empty content or content still holding the previous company's unedited + // notes; anything else is the dispatcher's typing and must never be overwritten. + if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) { + return current; + } + return { ...current, vendorNotes: roster.notes }; + }); + }, [vendorId, roster, baseline, setDraft]); + + async function fetchCurrentBaseline(vendorId: string): Promise { + try { + const current = await queryClient.fetchQuery({ + queryKey: queryKeys.vendors.roster("vendorId", vendorId), + queryFn: () => vendorCompanyRosterApi.get({ vendorId }), + }); + if (!current || current.companyId === null) { + return null; + } + return { + vendorId, + companyId: current.companyId, + rowVersion: current.rowVersion, + notes: current.notes, + }; + } catch { + toast.error(NOTES_SAVE_FAILED_MESSAGE); + return null; + } + } + + async function saveCompanyNotes(draft: WorkOrderWizardDraft): Promise { + if (!draft.vendorId) { + return; + } + const active = + baseline && baseline.vendorId === draft.vendorId + ? baseline + : await fetchCurrentBaseline(draft.vendorId); + if (!active) { + return; + } + const update = buildCompanyNotesUpdate(active, draft.vendorNotes); + if (!update) { + return; + } + try { + await saveNotes.mutateAsync(update); + } catch { + // The mutation already surfaces the note-save failure toast. + } + } + + return { saveCompanyNotes }; +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx index baadd66e..8b15610d 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx @@ -29,7 +29,7 @@ export function WizardStepLocationServiceSelect({ onPatch, }: WizardStepLocationServiceSelectProps) { const clearVendorIfPrimaryChanged = (nextPm: string): Partial => - nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; + nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {}; const selectSvc = (p: string) => { const selected = services.find((service) => service.value === p); diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index 1fbd6142..1d052b45 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -206,7 +206,7 @@ export function WizardStepVendorTime({ optional onClear={ draft.vendorId !== "" - ? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" }) + ? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" }) : undefined } > diff --git a/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts new file mode 100644 index 00000000..6f1c4081 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; + +export function useDashboardDrilldownFilters( + onApply: (filters: WorkOrderAdvancedFilters) => void, +): boolean { + const [searchParams, setSearchParams] = useSearchParams(); + const appliedRef = useRef(false); + // Hold onApply in a ref so a caller passing a fresh closure each render does not + // re-run the apply effect; it should fire only when the URL search params change. + const onApplyRef = useRef(onApply); + const [fromDashboard, setFromDashboard] = useState(false); + + useEffect(() => { + onApplyRef.current = onApply; + }, [onApply]); + + useEffect(() => { + if (appliedRef.current) { + return; + } + + const drilldown = parseDashboardDrilldownParams(searchParams); + if (!drilldown) { + return; + } + + appliedRef.current = true; + setFromDashboard(true); + onApplyRef.current(drilldown); + setSearchParams({}, { replace: true }); + }, [searchParams, setSearchParams]); + + return fromDashboard; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts index 3f15b3cb..42b3bde9 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts @@ -4,6 +4,7 @@ import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/ import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination"; import { buildSelectedDayGroup, @@ -70,9 +71,7 @@ export function useWorkOrdersTableDayView({ }); const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; const todayKey = - filters.weekMonday === currentWeekMonday() - ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup - : null; + filters.weekMonday === currentWeekMonday() ? deriveDayFields(todayIso()).dayGroup : null; return { scheduled, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index e12e040d..23fe3074 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,4 +1,5 @@ -import { Box } from "@mui/material"; +import { Box, Link as MuiLink } from "@mui/material"; +import { Link as RouterLink } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; @@ -8,6 +9,7 @@ import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/l import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; +import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; import { useAuthContext } from "@/providers/auth-context"; @@ -24,6 +26,7 @@ export default function WorkOrdersListPage() { const { user } = useAuthContext(); const page = useWorkOrdersListPage(); const { filters, tableData } = page; + const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters); return ( @@ -54,6 +57,12 @@ export default function WorkOrdersListPage() { } /> + {fromDashboard && ( + + Back to Dashboard + + )} + ; +function asString(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return ""; +} + +function asRecords(value: unknown): Record[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter( + (item): item is Record => + typeof item === "object" && item !== null && !Array.isArray(item), + ); +} + +function readField(data: Record, key: string): unknown { + const capitalized = key.charAt(0).toUpperCase() + key.slice(1); + const value = data[key] ?? data[capitalized]; + return value === null ? undefined : value; +} + +function toRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readCollection(value: unknown, key: string): Record[] { + const data = toRecord(value); + const collection = key ? (readField(data, key) ?? value) : value; + return asRecords(collection); +} + +/** + * Stats, Regions, Workload, and Performance share `DashboardStatsQueryDTO` + * (backend#145): the range dates ride along when present, and `dispatcherId` + * scopes the read to a single dispatcher (SH-336's picker — All sends nothing, + * My WOs / an individual sends that id). Vendor Insights and Trend take no such + * param and stay company-wide (SH-349). + */ +function scopedSearchParams( + range?: DashboardRangeParams, + dispatcherId?: string, +): Options | undefined { + const searchParams: Record = {}; + if (range?.dateFrom && range?.dateTo) { + searchParams.dateFrom = range.dateFrom; + searchParams.dateTo = range.dateTo; + } + if (dispatcherId) { + searchParams.dispatcherId = dispatcherId; + } + return Object.keys(searchParams).length > 0 ? { searchParams } : undefined; +} + +/** + * Workload and Performance are paged server-side (backend#126/#127): `page` is + * 1-based and always sent, the range dates ride along when present, and + * `dispatcherId` scopes to a single dispatcher (SH-336). + */ +function dispatcherPageSearchParams( + range: DashboardRangeParams | undefined, + page: number, + dispatcherId?: string, +): Options { + const searchParams: Record = { page }; + if (range?.dateFrom && range?.dateTo) { + searchParams.dateFrom = range.dateFrom; + searchParams.dateTo = range.dateTo; + } + if (dispatcherId) { + searchParams.dispatcherId = dispatcherId; + } + return { searchParams }; +} + +/** + * Reads the paging envelope the dispatcher endpoints return (`Page`, `PageSize`, + * `TotalDispatchers`). Falls back to the requested page, the default page size, + * and the item count for the legacy bare-array shape so older responses still + * render (with the pager hidden). + */ +function readDispatcherPageMeta( + raw: unknown, + requestedPage: number, + itemCount: number, +): { page: number; pageSize: number; totalDispatchers: number } { + const data = toRecord(raw); + const pageValue = readField(data, "page"); + const pageSizeValue = readField(data, "pageSize"); + const totalValue = readField(data, "totalDispatchers"); return { - open: toNumber(data.open ?? data.Open), - notDispatched: toNumber(data.notDispatched ?? data.NotDispatched), - completed: toNumber(data.completed ?? data.Completed), + page: pageValue == null ? requestedPage : toNumber(pageValue), + pageSize: pageSizeValue == null ? DISPATCHER_PAGE_SIZE : toNumber(pageSizeValue), + totalDispatchers: totalValue == null ? itemCount : toNumber(totalValue), }; } +function trendSearchParams(params?: DashboardTrendParams): Options | undefined { + if ( + !params || + (!params.dateFrom && !params.dateTo && !params.granularity && !params.range && !params.year) + ) { + return undefined; + } + + const searchParams: Record = {}; + if (params.dateFrom) searchParams.dateFrom = params.dateFrom; + if (params.dateTo) searchParams.dateTo = params.dateTo; + if (params.granularity) searchParams.granularity = params.granularity; + if (params.range) searchParams.range = params.range; + if (params.year) searchParams.year = params.year; + + return { searchParams }; +} + +const BREAKDOWN_TYPE_BY_LOWER = new Map( + WO_TYPES.map((type) => [type.toLowerCase(), type]), +); + +/** + * The backend breakdown object (backend#138) serializes its keys with no + * JsonPropertyName, so `PM`/`Emergency`/`Reactive`/`Overdue`/`Other` arrive + * lowercase. The card uses the row label as both display text and the + * `types` drill-down id, and `workOrderTypeDrilldownSearch` only accepts + * canonical `WOType` values — so map lowercase type keys back to their + * canonical labels. `other` is not a WOType: keep it as a labelled, + * non-drillable "Other" row. Unknown values (e.g. lifecycle labels from the + * legacy array shape) pass through unchanged. + */ +function normalizeBreakdownStatus(raw: string): string { + const trimmed = raw.trim(); + const canonical = BREAKDOWN_TYPE_BY_LOWER.get(trimmed.toLowerCase()); + if (canonical) return canonical; + if (trimmed.toLowerCase() === "other") return "Other"; + return trimmed; +} + +function mapBreakdownRow(raw: Record): DashboardBreakdownRow { + const status = asString( + readField(raw, "status") ?? readField(raw, "name") ?? readField(raw, "label"), + ); + return { + status: status ? normalizeBreakdownStatus(status) : UNMAPPED_REGION_LABEL, + count: toNumber(readField(raw, "count") ?? readField(raw, "value")), + }; +} + +function mapBreakdown(value: unknown): DashboardBreakdownRow[] { + if (Array.isArray(value)) { + return asRecords(value).map(mapBreakdownRow); + } + + if (typeof value === "object" && value !== null) { + return Object.entries(value as Record) + .map(([status, count]) => ({ + status: normalizeBreakdownStatus(status), + count: toNumber(count), + })) + .filter((row) => row.status !== ""); + } + + return []; +} + +function mapDispatcherIdentity(raw: Record): { + dispatcherId: string; + dispatcherName: string; +} { + return { + dispatcherId: asString( + readField(raw, "dispatcherId") ?? readField(raw, "id") ?? readField(raw, "userId"), + ), + dispatcherName: asString(readField(raw, "dispatcherName") ?? readField(raw, "name")), + }; +} + +function mapStatusDistribution(value: unknown): DashboardStatusBucketRow[] { + return asRecords(value) + .map((row) => ({ + status: asString( + readField(row, "status") ?? readField(row, "name") ?? readField(row, "label"), + ), + count: toNumber(readField(row, "count") ?? readField(row, "value")), + })) + .filter((row) => row.status !== ""); +} + +function mapDashboardStats(raw: unknown): DashboardStats { + const data = toRecord(raw); + const average = readField(data, "averageResolutionDays"); + + return { + total: toNumber(readField(data, "total")), + open: toNumber(readField(data, "open")), + notDispatched: toNumber(readField(data, "notDispatched")), + completed: toNumber(readField(data, "completed")), + dueCount: toNumber(readField(data, "dueCount")), + completedDueCount: toNumber(readField(data, "completedDueCount")), + completionRate: toNumber(readField(data, "completionRate")), + averageResolutionDays: average == null ? null : toNumber(average), + scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")), + pendingUplifts: toNumber(readField(data, "pendingUplifts")), + avetaPending: toNumber(readField(data, "avetaPending")), + breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")), + statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")), + }; +} + +function mapDispatcherWorkload(raw: unknown): DispatcherWorkloadRow[] { + return readCollection(raw, "items").map((rawRow) => { + const identity = mapDispatcherIdentity(rawRow); + return { + ...identity, + openWorkOrders: toNumber( + readField(rawRow, "openWorkOrders") ?? + readField(rawRow, "openCount") ?? + readField(rawRow, "open"), + ), + totalWorkOrders: toNumber( + readField(rawRow, "totalWorkOrders") ?? + readField(rawRow, "totalCount") ?? + readField(rawRow, "scheduledWorkOrders") ?? + readField(rawRow, "scheduled"), + ), + }; + }); +} + +function mapDispatcherPerformance(raw: unknown): DispatcherPerformanceRow[] { + return readCollection(raw, "items").map((rawRow) => { + const identity = mapDispatcherIdentity(rawRow); + return { + ...identity, + completionRate: toNumber( + readField(rawRow, "completionRate") ?? readField(rawRow, "onTimeRate"), + ), + averageResolutionDays: + readField(rawRow, "averageResolutionDays") == null + ? null + : toNumber(readField(rawRow, "averageResolutionDays")), + assignedCount: toNumber(readField(rawRow, "assignedCount") ?? readField(rawRow, "assigned")), + completedCount: toNumber( + readField(rawRow, "completedCount") ?? readField(rawRow, "completed"), + ), + }; + }); +} + +function mapRegions(raw: unknown): RegionWorkOrdersRow[] { + return readCollection(raw, "items").map((rawRow) => { + const region = + asString(readField(rawRow, "region") ?? readField(rawRow, "name")) || UNMAPPED_REGION_LABEL; + return { + region, + workOrderCount: toNumber( + readField(rawRow, "workOrderCount") ?? + readField(rawRow, "count") ?? + readField(rawRow, "total"), + ), + }; + }); +} + +function mapVendorInsights(raw: unknown): VendorInsightRow[] { + return readCollection(raw, "items").map((rawRow) => ({ + vendorId: asString( + readField(rawRow, "vendorId") ?? + readField(rawRow, "vendorCompanyId") ?? + readField(rawRow, "id"), + ), + vendorName: asString( + readField(rawRow, "vendorName") ?? + readField(rawRow, "vendorCompanyName") ?? + readField(rawRow, "companyName") ?? + readField(rawRow, "name"), + ), + completionRate: toNumber(readField(rawRow, "completionRate")), + rescheduleRate: toNumber(readField(rawRow, "rescheduleRate")), + averageResolutionDays: + readField(rawRow, "averageResolutionDays") == null + ? null + : toNumber(readField(rawRow, "averageResolutionDays")), + totalJobs: toNumber(readField(rawRow, "totalJobs")), + })); +} + +function mapTrendGranularity(value: unknown): DashboardTrendGranularity | null { + const granularity = asString(value).toLowerCase(); + return granularity === "day" || granularity === "week" || granularity === "month" + ? granularity + : null; +} + +function mapDashboardTrend(raw: unknown): DashboardTrend { + const data = toRecord(raw); + const buckets = readCollection(data, "buckets"); + const legacyPoints = readCollection(data, "points"); + const points = (buckets.length > 0 ? buckets : legacyPoints).map((rawPoint) => ({ + date: asString(readField(rawPoint, "date")), + label: asString( + readField(rawPoint, "label") ?? readField(rawPoint, "period") ?? readField(rawPoint, "date"), + ), + total: toNumber( + readField(rawPoint, "total") ?? readField(rawPoint, "count") ?? readField(rawPoint, "value"), + ), + open: toNumber(readField(rawPoint, "open")), + completed: toNumber(readField(rawPoint, "completed")), + canceled: toNumber(readField(rawPoint, "canceled")), + overdue: toNumber(readField(rawPoint, "overdue")), + isCurrent: readField(rawPoint, "isCurrent") === true, + })); + + return { granularity: mapTrendGranularity(readField(data, "granularity")), points }; +} + export const dashboardApi = { - getStats: async (): Promise => { - const data = await apiGet(API_PATHS.dashboard.stats); + getStats: async ( + range?: DashboardRangeParams, + dispatcherId?: string, + ): Promise => { + const data = await apiGet( + API_PATHS.dashboard.stats, + scopedSearchParams(range, dispatcherId), + ); + return mapDashboardStats(handleApiResponse(data)); + }, + getWorkload: async ( + range?: DashboardRangeParams, + page = 1, + dispatcherId?: string, + ): Promise> => { + const data = await apiGet( + API_PATHS.dashboard.workload, + dispatcherPageSearchParams(range, page, dispatcherId), + ); const response = handleApiResponse(data); - return mapDashboardStats(response); + const items = mapDispatcherWorkload(response); + return { items, ...readDispatcherPageMeta(response, page, items.length) }; + }, + getPerformance: async ( + range?: DashboardRangeParams, + page = 1, + dispatcherId?: string, + ): Promise> => { + const data = await apiGet( + API_PATHS.dashboard.performance, + dispatcherPageSearchParams(range, page, dispatcherId), + ); + const response = handleApiResponse(data); + const items = mapDispatcherPerformance(response); + return { items, ...readDispatcherPageMeta(response, page, items.length) }; + }, + getRegions: async ( + range?: DashboardRangeParams, + dispatcherId?: string, + ): Promise => { + const data = await apiGet( + API_PATHS.dashboard.regions, + scopedSearchParams(range, dispatcherId), + ); + return mapRegions(handleApiResponse(data)); + }, + getVendorInsights: async (): Promise => { + const data = await apiGet(API_PATHS.dashboard.vendorInsights); + return mapVendorInsights(handleApiResponse(data)); + }, + getTrend: async (params?: DashboardTrendParams): Promise => { + const data = await apiGet(API_PATHS.dashboard.trend, trendSearchParams(params)); + return mapDashboardTrend(handleApiResponse(data)); }, }; diff --git a/src/domain/dashboard/types/dashboard-dispatcher-page.ts b/src/domain/dashboard/types/dashboard-dispatcher-page.ts new file mode 100644 index 00000000..686b4f63 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-dispatcher-page.ts @@ -0,0 +1,10 @@ +/** Backend pages both dispatcher tables at 10 rows (backend#126/#127). */ +export const DISPATCHER_PAGE_SIZE = 10; + +/** A single page of dispatcher rows plus the paging metadata the tables need. */ +export interface DispatcherPage { + items: TRow[]; + page: number; + pageSize: number; + totalDispatchers: number; +} diff --git a/src/domain/dashboard/types/dashboard-performance.ts b/src/domain/dashboard/types/dashboard-performance.ts new file mode 100644 index 00000000..03c49371 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-performance.ts @@ -0,0 +1,8 @@ +export interface DispatcherPerformanceRow { + dispatcherId: string; + dispatcherName: string; + completionRate: number; + averageResolutionDays: number | null; + assignedCount: number; + completedCount: number; +} diff --git a/src/domain/dashboard/types/dashboard-range.ts b/src/domain/dashboard/types/dashboard-range.ts new file mode 100644 index 00000000..15cfd155 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-range.ts @@ -0,0 +1,10 @@ +export const DASHBOARD_RANGES = ["this-week", "last-3-months", "today", "all-time"] as const; + +export type DashboardRangeKey = (typeof DASHBOARD_RANGES)[number]; + +export const DEFAULT_DASHBOARD_RANGE: DashboardRangeKey = "this-week"; + +export interface DashboardRangeParams { + dateFrom?: string; + dateTo?: string; +} diff --git a/src/domain/dashboard/types/dashboard-regions.ts b/src/domain/dashboard/types/dashboard-regions.ts new file mode 100644 index 00000000..c5b1cab9 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-regions.ts @@ -0,0 +1,6 @@ +export const UNMAPPED_REGION_LABEL = "Unmapped/Other"; + +export interface RegionWorkOrdersRow { + region: string; + workOrderCount: number; +} diff --git a/src/domain/dashboard/types/dashboard-stats.ts b/src/domain/dashboard/types/dashboard-stats.ts index 7b7aee4b..1394a801 100644 --- a/src/domain/dashboard/types/dashboard-stats.ts +++ b/src/domain/dashboard/types/dashboard-stats.ts @@ -1,5 +1,25 @@ +export interface DashboardBreakdownRow { + status: string; + count: number; +} + +export interface DashboardStatusBucketRow { + status: string; + count: number; +} + export interface DashboardStats { + total: number; open: number; notDispatched: number; completed: number; + dueCount: number; + completedDueCount: number; + completionRate: number; + averageResolutionDays: number | null; + scheduledTomorrow: number; + pendingUplifts: number; + avetaPending: number; + breakdown: DashboardBreakdownRow[]; + statusDistribution: DashboardStatusBucketRow[]; } diff --git a/src/domain/dashboard/types/dashboard-trend.ts b/src/domain/dashboard/types/dashboard-trend.ts new file mode 100644 index 00000000..d9c8eff5 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-trend.ts @@ -0,0 +1,25 @@ +export type DashboardTrendGranularity = "day" | "week" | "month"; + +export interface DashboardTrendParams { + dateFrom?: string; + dateTo?: string; + granularity?: DashboardTrendGranularity; + range?: "3m"; + year?: number; +} + +export interface DashboardTrendPoint { + date: string; + label: string; + total: number; + open: number; + completed: number; + canceled: number; + overdue: number; + isCurrent: boolean; +} + +export interface DashboardTrend { + granularity: DashboardTrendGranularity | null; + points: DashboardTrendPoint[]; +} diff --git a/src/domain/dashboard/types/dashboard-vendor-insights.ts b/src/domain/dashboard/types/dashboard-vendor-insights.ts new file mode 100644 index 00000000..b8f1c859 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-vendor-insights.ts @@ -0,0 +1,8 @@ +export interface VendorInsightRow { + vendorId: string; + vendorName: string; + completionRate: number; + rescheduleRate: number; + averageResolutionDays: number | null; + totalJobs: number; +} diff --git a/src/domain/dashboard/types/dashboard-workload.ts b/src/domain/dashboard/types/dashboard-workload.ts new file mode 100644 index 00000000..a7a8f806 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-workload.ts @@ -0,0 +1,6 @@ +export interface DispatcherWorkloadRow { + dispatcherId: string; + dispatcherName: string; + openWorkOrders: number; + totalWorkOrders: number; +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-performance.ts b/src/domain/dashboard/use-cases/use-dashboard-performance.ts new file mode 100644 index 00000000..3e7c4f71 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-performance.ts @@ -0,0 +1,22 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { DispatcherPage } from "@/domain/dashboard/types/dashboard-dispatcher-page"; +import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardPerformance( + range: DashboardRangeKey, + page = 1, + dispatcherId: string | null = null, + enabled = true, +): UseQueryResult, Error> { + return useQuery({ + queryKey: queryKeys.dashboard.performance(range, page, dispatcherId), + queryFn: () => + dashboardApi.getPerformance(dashboardRangeParams(range), page, dispatcherId ?? undefined), + staleTime: 60 * 1000, + enabled, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-regions.ts b/src/domain/dashboard/use-cases/use-dashboard-regions.ts new file mode 100644 index 00000000..787d7477 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-regions.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { RegionWorkOrdersRow } from "@/domain/dashboard/types/dashboard-regions"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardRegions( + range: DashboardRangeKey, + dispatcherId: string | null = null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.regions(range, dispatcherId), + queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range), dispatcherId ?? undefined), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-stats.ts b/src/domain/dashboard/use-cases/use-dashboard-stats.ts index eb1b9a5f..9266d7ba 100644 --- a/src/domain/dashboard/use-cases/use-dashboard-stats.ts +++ b/src/domain/dashboard/use-cases/use-dashboard-stats.ts @@ -1,12 +1,17 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useDashboardStats(): UseQueryResult { +export function useDashboardStats( + range: DashboardRangeKey, + dispatcherId: string | null = null, +): UseQueryResult { return useQuery({ - queryKey: queryKeys.dashboard.stats(), - queryFn: () => dashboardApi.getStats(), + queryKey: queryKeys.dashboard.stats(range, dispatcherId), + queryFn: () => dashboardApi.getStats(dashboardRangeParams(range), dispatcherId ?? undefined), staleTime: 60 * 1000, }); } diff --git a/src/domain/dashboard/use-cases/use-dashboard-trend.ts b/src/domain/dashboard/use-cases/use-dashboard-trend.ts new file mode 100644 index 00000000..ca780bb6 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-trend.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { + DashboardTrend, + DashboardTrendParams, +} from "@/domain/dashboard/types/dashboard-trend"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardTrend( + params?: DashboardTrendParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.trend(params), + queryFn: () => dashboardApi.getTrend(params), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts b/src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts new file mode 100644 index 00000000..1544dd52 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts @@ -0,0 +1,12 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardVendorInsights(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.vendorInsights(), + queryFn: () => dashboardApi.getVendorInsights(), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-workload.ts b/src/domain/dashboard/use-cases/use-dashboard-workload.ts new file mode 100644 index 00000000..5a970598 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-workload.ts @@ -0,0 +1,22 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { DispatcherPage } from "@/domain/dashboard/types/dashboard-dispatcher-page"; +import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardWorkload( + range: DashboardRangeKey, + page = 1, + dispatcherId: string | null = null, + enabled = true, +): UseQueryResult, Error> { + return useQuery({ + queryKey: queryKeys.dashboard.workload(range, page, dispatcherId), + queryFn: () => + dashboardApi.getWorkload(dashboardRangeParams(range), page, dispatcherId ?? undefined), + staleTime: 60 * 1000, + enabled, + }); +} diff --git a/src/domain/dashboard/utils/dashboard-range-utils.ts b/src/domain/dashboard/utils/dashboard-range-utils.ts new file mode 100644 index 00000000..3efc2ca4 --- /dev/null +++ b/src/domain/dashboard/utils/dashboard-range-utils.ts @@ -0,0 +1,73 @@ +import { DEFAULT_DASHBOARD_RANGE } from "@/domain/dashboard/types/dashboard-range"; +import type { + DashboardRangeKey, + DashboardRangeParams, +} from "@/domain/dashboard/types/dashboard-range"; + +export const BUSINESS_TIME_ZONE = "America/New_York"; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function businessTodayIso(now: Date = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: BUSINESS_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} + +export function isIsoDate(value: string): boolean { + return ISO_DATE_PATTERN.test(value); +} + +export function addDaysIso(isoDate: string, days: number): string { + const [year, month, day] = isoDate.split("-").map(Number); + const shifted = new Date(Date.UTC(year, month - 1, day + days)); + return shifted.toISOString().slice(0, 10); +} + +export function mondayOf(isoDate: string): string { + const [year, month, day] = isoDate.split("-").map(Number); + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + return addDaysIso(isoDate, -((weekday + 6) % 7)); +} + +export function monthStartIso(isoDate: string): string { + return `${isoDate.slice(0, 7)}-01`; +} + +export function monthEndIso(isoDate: string): string { + const year = Number(isoDate.slice(0, 4)); + const month = Number(isoDate.slice(5, 7)); + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return `${isoDate.slice(0, 7)}-${String(lastDay).padStart(2, "0")}`; +} + +function shiftMonthsIso(isoDate: string, months: number): string { + const year = Number(isoDate.slice(0, 4)); + const month = Number(isoDate.slice(5, 7)); + const shifted = new Date(Date.UTC(year, month - 1 + months, 1)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}-01`; +} + +export function dashboardRangeParams( + range: DashboardRangeKey = DEFAULT_DASHBOARD_RANGE, + today: string = businessTodayIso(), +): DashboardRangeParams { + switch (range) { + case "today": + return { dateFrom: today, dateTo: today }; + case "this-week": { + const monday = mondayOf(today); + return { dateFrom: monday, dateTo: addDaysIso(monday, 4) }; + } + case "last-3-months": + return { + dateFrom: monthStartIso(shiftMonthsIso(today, -2)), + dateTo: monthEndIso(today), + }; + case "all-time": + return {}; + } +} diff --git a/src/domain/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts new file mode 100644 index 00000000..c83d0592 --- /dev/null +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -0,0 +1,96 @@ +import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard"; +import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { + addDaysIso, + businessTodayIso, + dashboardRangeParams, +} from "@/domain/dashboard/utils/dashboard-range-utils"; + +export const WORK_ORDERS_ROUTE = "/workorders"; +export const UPLIFTS_ROUTE = "/uplifts"; + +export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filter( + (status) => status !== "Completed", +); + +export function workOrderDrilldownUrl(search: URLSearchParams): string { + const query = search.toString(); + return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE; +} + +function setDateRange(search: URLSearchParams, range: DashboardRangeKey): void { + const params = dashboardRangeParams(range); + if (params.dateFrom && params.dateTo) { + search.set("dateFrom", params.dateFrom); + search.set("dateTo", params.dateTo); + } +} + +export function workOrderRangeDrilldownSearch(range: DashboardRangeKey): URLSearchParams { + const search = new URLSearchParams(); + setDateRange(search, range); + return search; +} + +export function workOrderStatusDrilldownSearch( + status: string, + range: DashboardRangeKey, +): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("statuses", status); + return search; +} + +export function workOrderTypeDrilldownSearch( + type: string, + range: DashboardRangeKey, +): URLSearchParams | null { + if (!WO_TYPES.includes(type as WOType)) return null; + const search = workOrderRangeDrilldownSearch(range); + search.set("types", type); + return search; +} + +export function workOrderDispatcherDrilldownSearch( + dispatcherId: string, + range: DashboardRangeKey, +): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("dispatchers", dispatcherId); + return search; +} + +export function workOrderRegionDrilldownSearch( + region: string, + range: DashboardRangeKey, +): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("regions", region); + return search; +} + +export function scheduledTomorrowDrilldownSearch( + today: string = businessTodayIso(), +): URLSearchParams { + const tomorrow = addDaysIso(today, 1); + const search = new URLSearchParams(); + search.set("dateFrom", tomorrow); + search.set("dateTo", tomorrow); + return search; +} + +export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams { + // The Aveta Pending count (backend#138 / SH-294) is: AvetaRequired, scheduled + // today or tomorrow, open, and missing its Aveta-category document. The board + // has no missing-Aveta-document filter, so the closest reachable set is the + // Aveta-required work in the two-day open window. `docs=No` is deliberately + // NOT sent: it filters on completion-document status, a different document + // from the Aveta clearance one, so it would narrow to the wrong rows. + const search = new URLSearchParams(); + search.set("dateFrom", today); + search.set("dateTo", addDaysIso(today, 1)); + search.set("aveta", "1"); + search.set("statuses", OPEN_WIZARD_STATUSES.join(",")); + return search; +} diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts index b9cb1455..0e53e989 100644 --- a/src/domain/team-members/api/team-members-api.ts +++ b/src/domain/team-members/api/team-members-api.ts @@ -1,11 +1,16 @@ -import { apiGet, apiPost } from "@/api/api"; +import { apiGet, apiPost, apiPut } from "@/api/api"; import { API_PATHS } from "@/api/api-paths"; import { handleApiResponse } from "@/api/handle-api-response"; -import { mapTeamMemberListItems } from "@/domain/team-members/mappers/team-member-mapper"; +import { + mapTeamMemberDetail, + mapTeamMemberListItems, +} from "@/domain/team-members/mappers/team-member-mapper"; import type { CreateTeamMemberInput, TeamMemberCreated, + TeamMemberDetail, TeamMemberListResult, + UpdateTeamMemberInput, } from "@/domain/team-members/types/team-member"; export const teamMembersApi = { @@ -19,4 +24,19 @@ export const teamMembersApi = { const data = await apiPost(API_PATHS.teamMember.create, input); return handleApiResponse(data); }, + + getDetail: async (id: string | number): Promise => { + const data = await apiGet( + `${API_PATHS.teamMember.detail}/${encodeURIComponent(String(id))}`, + ); + return mapTeamMemberDetail(handleApiResponse(data)); + }, + + update: async (id: string | number, input: UpdateTeamMemberInput): Promise => { + const data = await apiPut( + `${API_PATHS.teamMember.detail}/${encodeURIComponent(String(id))}`, + input, + ); + return mapTeamMemberDetail(handleApiResponse(data)); + }, }; diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts index 0d7d43e6..d283da28 100644 --- a/src/domain/team-members/mappers/team-member-mapper.ts +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -1,4 +1,9 @@ -import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; +import type { + TeamMemberDetail, + TeamMemberListItem, + TeamPermissionState, + TeamPermissionValue, +} from "@/domain/team-members/types/team-member"; function readString(record: Record, ...keys: string[]): string { for (const key of keys) { @@ -18,6 +23,41 @@ function readBoolean(record: Record, ...keys: string[]): boolea return false; } +function readStringArray(record: Record, ...keys: string[]): string[] { + for (const key of keys) { + const value = record[key]; + if (Array.isArray(value)) + return value.filter((item): item is string => typeof item === "string"); + if (typeof value === "string" && value.trim()) return [value]; + } + return []; +} + +function readPermissionState(value: unknown): TeamPermissionState { + if (value === 1 || value === "Allow") return 1; + if (value === 2 || value === "Deny") return 2; + return 0; +} + +function readPermissions(record: Record): TeamPermissionValue[] { + const value = record.permissions ?? record.Permissions; + if (!Array.isArray(value)) return []; + + return value.flatMap((item) => { + if (typeof item !== "object" || item === null) return []; + const permission = item as Record; + const permissionKey = readString(permission, "permissionKey", "PermissionKey"); + if (!permissionKey) return []; + return [ + { + permissionKey, + overrideState: readPermissionState(permission.overrideState ?? permission.OverrideState), + isGranted: readBoolean(permission, "isGranted", "IsGranted"), + }, + ]; + }); +} + export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const id = record.id ?? record.Id; @@ -44,3 +84,13 @@ export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[] { return rawItems.map(mapTeamMemberListItem); } + +export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail { + const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const listItem = mapTeamMemberListItem(record); + return { + ...listItem, + serviceAreas: readStringArray(record, "serviceAreas", "ServiceAreas"), + permissions: readPermissions(record), + }; +} diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts index 30bc05e6..5b2d07c5 100644 --- a/src/domain/team-members/types/team-member.ts +++ b/src/domain/team-members/types/team-member.ts @@ -34,6 +34,30 @@ export interface TeamMemberCreated { pendingRegistration: boolean; } +export type TeamPermissionState = 0 | 1 | 2; + +export interface TeamPermissionValue { + permissionKey: string; + overrideState: TeamPermissionState; + isGranted: boolean; +} + +export interface TeamMemberDetail extends TeamMemberListItem { + serviceAreas: string[]; + permissions: TeamPermissionValue[]; +} + +export interface UpdateTeamMemberInput { + name: string; + role: TeamMemberRole; + color: string; + email: string; + phone?: string; + serviceAreas: string[]; + permissionOverrides: Record; + isActive: boolean; +} + export interface TeamMemberListResult { items: TeamMemberListItem[]; } diff --git a/src/domain/team-members/use-cases/use-team-member-detail.ts b/src/domain/team-members/use-cases/use-team-member-detail.ts new file mode 100644 index 00000000..3622ab5c --- /dev/null +++ b/src/domain/team-members/use-cases/use-team-member-detail.ts @@ -0,0 +1,45 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { + TeamMemberDetail, + UpdateTeamMemberInput, +} from "@/domain/team-members/types/team-member"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useTeamMemberDetail( + id: string | number | null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.teamMembers.detail(id ?? ""), + queryFn: () => teamMembersApi.getDetail(id as string | number), + enabled: id !== null, + }); +} + +interface UpdateVariables { + id: string | number; + input: UpdateTeamMemberInput; +} + +export function useUpdateTeamMember(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, input }) => teamMembersApi.update(id, input), + onSuccess: (member) => { + void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all }); + void queryClient.setQueryData(queryKeys.teamMembers.detail(member.id), member); + toast.success("Team member updated."); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update team member"); + }, + }); +} diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 890d7116..21937764 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -2,7 +2,11 @@ import { HTTPError } from "ky"; import { API_PATHS } from "@/api/api-paths"; import { apiGet, apiPost, apiRequestRaw } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; -import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper"; +import { + mapUpliftQueueExposureTotal, + mapUpliftQueueItems, + mapUpliftRequests, +} from "@/domain/uplifts/mappers/uplift-mapper"; import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; @@ -57,6 +61,7 @@ export const upliftsApi = { totalCount: total, page: params.page ?? 1, pageSize: params.pageSize ?? 25, + pendingExposureTotal: mapUpliftQueueExposureTotal(response), }; }, @@ -81,10 +86,22 @@ export const upliftsApi = { await apiPost(`${API_PATHS.rest.uplifts}/${id}/reject`, { note }); }, + canApprove: async (tier: number): Promise => { + const data = await apiGet(`${API_PATHS.rest.uplifts}/can-approve`, { + searchParams: { tier }, + }); + const response = handleApiResponse<{ canApprove?: unknown }>(data); + return response?.canApprove === true; + }, + requestChanges: async (id: string | number, note: string): Promise => { await apiPost(`${API_PATHS.rest.uplifts}/${id}/request-changes`, { note }); }, + revoke: async (id: string | number, reason: string): Promise => { + await apiPost(`${API_PATHS.rest.uplifts}/${id}/revoke`, { note: reason }); + }, + downloadEvidence: async ( id: string | number, fallbackFileName = "uplift-evidence", diff --git a/src/domain/uplifts/mappers/uplift-mapper.ts b/src/domain/uplifts/mappers/uplift-mapper.ts index 2753ef92..300a6877 100644 --- a/src/domain/uplifts/mappers/uplift-mapper.ts +++ b/src/domain/uplifts/mappers/uplift-mapper.ts @@ -39,6 +39,14 @@ function readOptionalId( return null; } +function readOptionalBool(record: Record, ...keys: string[]): boolean | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + } + return null; +} + export function mapUpliftRequest(raw: unknown): UpliftRequest { const item = asRecord(raw); return { @@ -49,7 +57,13 @@ export function mapUpliftRequest(raw: unknown): UpliftRequest { delta: readNumber(item, "delta", "Delta") ?? 0, vendorReason: readString(item, "vendorReason", "VendorReason"), requestedAt: readString(item, "requestedAt", "RequestedAt"), - requestedByVendorName: readString(item, "requestedByVendorName", "RequestedByVendorName"), + requestedByVendorName: readString( + item, + "requestedByName", + "RequestedByName", + "requestedByVendorName", + "RequestedByVendorName", + ), decidedAt: readString(item, "decidedAt", "DecidedAt"), decidedByName: readString(item, "decidedByName", "DecidedByName"), decisionNote: readString(item, "decisionNote", "DecisionNote"), @@ -79,9 +93,86 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"), workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"), dispatchId: readOptionalId(item, "dispatchId", "DispatchId"), + woNumber: readString(item, "woNumber", "WoNumber", "workOrderNumber", "WorkOrderNumber"), + site: readString( + item, + "workOrderSite", + "WorkOrderSite", + "site", + "Site", + "siteCode", + "SiteCode", + "locationName", + "LocationName", + ), + serviceName: readString( + item, + "workOrderService", + "WorkOrderService", + "serviceName", + "ServiceName", + "service", + "Service", + "trade", + "Trade", + ), + attachmentCount: readNumber( + item, + "attachmentCount", + "AttachmentCount", + "attachmentsCount", + "AttachmentsCount", + ), + approvedOnWoAuto: readNumber( + item, + "workOrderAutoApprovedTotal", + "WorkOrderAutoApprovedTotal", + "approvedOnWoAuto", + "ApprovedOnWoAuto", + "autoApprovedTotal", + "AutoApprovedTotal", + ), + approvedOnWoAdmin: readNumber( + item, + "workOrderAdminApprovedTotal", + "WorkOrderAdminApprovedTotal", + "approvedOnWoAdmin", + "ApprovedOnWoAdmin", + "adminApprovedTotal", + "AdminApprovedTotal", + ), + approvedOnWoTotal: readNumber( + item, + "workOrderApprovedExposureTotal", + "WorkOrderApprovedExposureTotal", + "approvedOnWoTotal", + "ApprovedOnWoTotal", + "approvedOnWorkOrderTotal", + "ApprovedOnWorkOrderTotal", + ), + workOrderClosed: readOptionalBool( + item, + "workOrderClosed", + "WorkOrderClosed", + "isWorkOrderClosed", + "IsWorkOrderClosed", + ), }; } export function mapUpliftQueueItems(rawItems: unknown[]): UpliftQueueItem[] { return rawItems.map(mapUpliftQueueItem); } + +export function mapUpliftQueueExposureTotal(raw: unknown): number | null { + const record = asRecord(raw); + return readNumber( + record, + "pendingExposureTotal", + "PendingExposureTotal", + "totalPendingExposure", + "TotalPendingExposure", + "exposureTotal", + "ExposureTotal", + ); +} diff --git a/src/domain/uplifts/types/uplift.ts b/src/domain/uplifts/types/uplift.ts index 2bd5406e..2e46d805 100644 --- a/src/domain/uplifts/types/uplift.ts +++ b/src/domain/uplifts/types/uplift.ts @@ -43,6 +43,14 @@ export interface UpliftQueueItem extends UpliftRequest { vendorCompanyName: string; workOrderId: string | number | null; dispatchId: string | number | null; + woNumber: string; + site: string; + serviceName: string; + attachmentCount: number | null; + approvedOnWoAuto: number | null; + approvedOnWoAdmin: number | null; + approvedOnWoTotal: number | null; + workOrderClosed: boolean | null; } export interface UpliftsQueueParams { @@ -57,4 +65,5 @@ export interface UpliftsQueueResult { totalCount: number; page: number; pageSize: number; + pendingExposureTotal: number | null; } diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts index 78a2326e..1de4d83b 100644 --- a/src/domain/uplifts/use-cases/use-uplift-actions.ts +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -26,6 +26,9 @@ function invalidateUpliftQueries( dispatchId?: string | number, ) { void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all }); + // Queue decisions update the same uplift records rendered by Work Order views. + // Invalidate that shared cache even when the queue has no dispatch context. + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); if (dispatchId !== undefined && dispatchId !== "") { void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) }); void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) }); @@ -33,14 +36,32 @@ function invalidateUpliftQueries( } } +function invalidateWorkOrderUpliftQueries(queryClient: ReturnType) { + void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); +} + export interface ApproveUpliftInput { id: string | number; note?: string; + amount?: number; + woNumber?: string; } export interface DecisionUpliftInput { id: string | number; note: string; + amount?: number; + woNumber?: string; +} + +function decisionAmount(amount: number | undefined): string { + if (amount == null || !Number.isFinite(amount)) return "the requested amount"; + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); +} + +function decisionWorkOrder(woNumber: string | undefined): string { + return woNumber?.trim() || "the work order"; } export function useApproveUplift( @@ -49,11 +70,12 @@ export function useApproveUplift( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, note }: { id: string | number; note?: string }) => - upliftsApi.approve(id, note), - onSuccess: () => { + mutationFn: ({ id, note }: ApproveUpliftInput) => upliftsApi.approve(id, note), + onSuccess: (_, variables) => { invalidateUpliftQueries(queryClient, dispatchId); - toast.success("Uplift approved — NTE updated"); + toast.success( + `Uplift approved — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); }, onError: (error: Error) => { toast.error(error.message || "Failed to approve uplift"); @@ -67,11 +89,12 @@ export function useRejectUplift( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, note }: { id: string | number; note: string }) => - upliftsApi.reject(id, note), - onSuccess: () => { + mutationFn: ({ id, note }: DecisionUpliftInput) => upliftsApi.reject(id, note), + onSuccess: (_, variables) => { invalidateUpliftQueries(queryClient, dispatchId); - toast.success("Uplift rejected"); + toast.success( + `Uplift rejected — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); }, onError: (error: Error) => { toast.error(error.message || "Failed to reject uplift"); @@ -96,3 +119,27 @@ export function useRequestChangesUplift( }, }); } + +export interface RevokeUpliftInput { + upliftId: string | number; + reason: string; + amount?: number; + woNumber?: string; +} + +export function useRevokeUplift(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ upliftId, reason }: RevokeUpliftInput) => upliftsApi.revoke(upliftId, reason), + onSuccess: (_, variables) => { + invalidateWorkOrderUpliftQueries(queryClient); + toast.success( + `Uplift revoked — ${decisionAmount(variables.amount)} on ${decisionWorkOrder(variables.woNumber)}`, + ); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to revoke uplift"); + }, + }); +} diff --git a/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts new file mode 100644 index 00000000..f02e7664 --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplift-approval-controller.ts @@ -0,0 +1,75 @@ +import { useState } from "react"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { + useApproveUplift, + useRejectUplift, + useRevokeUplift, +} from "@/domain/uplifts/use-cases/use-uplift-actions"; + +export function useUpliftApprovalController() { + const [detailItem, setDetailItem] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const approveUplift = useApproveUplift(); + const rejectUplift = useRejectUplift(); + const revokeUplift = useRevokeUplift(); + + const handleApprove = (row: UpliftQueueItem) => { + approveUplift.mutate({ id: row.id, amount: row.delta, woNumber: row.woNumber }); + setDetailItem(null); + }; + + const handleRejectRequest = (row: UpliftQueueItem) => { + setDetailItem(null); + setRejectTarget(row); + }; + + const handleRevokeRequest = (row: UpliftQueueItem) => { + setDetailItem(null); + setRevokeTarget(row); + }; + + const handleRejectConfirm = (reason: string) => { + if (!rejectTarget) return; + rejectUplift.mutate( + { + id: rejectTarget.id, + note: reason, + amount: rejectTarget.delta, + woNumber: rejectTarget.woNumber, + }, + { onSuccess: () => setRejectTarget(null) }, + ); + }; + + const handleRevokeConfirm = (reason: string) => { + if (!revokeTarget) return; + revokeUplift.mutate( + { + upliftId: revokeTarget.id, + reason, + amount: revokeTarget.delta, + woNumber: revokeTarget.woNumber, + }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }; + + return { + detailItem, + rejectTarget, + revokeTarget, + approvePending: approveUplift.isPending, + rejectPending: rejectUplift.isPending, + revokePending: revokeUplift.isPending, + handleApprove, + handleRejectRequest, + handleRevokeRequest, + handleRejectConfirm, + handleRevokeConfirm, + closeDetail: () => setDetailItem(null), + closeReject: () => setRejectTarget(null), + closeRevoke: () => setRevokeTarget(null), + openDetail: setDetailItem, + }; +} diff --git a/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts b/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts new file mode 100644 index 00000000..1b9dd971 --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplifts-can-approve.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +/** + * Backend-authoritative answer to "may the current user decide uplifts at this + * tier?" (GET uplifts/can-approve). Combines with the per-row canDecide signal + * so decision controls stay disabled for roles the backend would refuse. + */ +export function useUpliftsCanApprove(tier: number): UseQueryResult { + return useQuery({ + queryKey: queryKeys.uplifts.canApprove(tier), + queryFn: () => upliftsApi.canApprove(tier), + staleTime: 5 * 60 * 1000, + retry: 1, + }); +} diff --git a/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts b/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts new file mode 100644 index 00000000..9e98976b --- /dev/null +++ b/src/domain/vendors/use-cases/use-save-vendor-company-notes.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api"; +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import type { VendorCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-notes-patch"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export const NOTES_SAVE_FAILED_MESSAGE = + "Work order created, but the vendor notes could not be saved."; + +/** Writes company-level vendor notes and refreshes every vendor view that shows them. */ +export function useSaveVendorCompanyNotes(): UseMutationResult< + VendorCompanyRoster, + unknown, + VendorCompanyNotesUpdate +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ companyId, patch }: VendorCompanyNotesUpdate) => + vendorCompanyRosterApi.addTechnicians(companyId, patch), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all }); + }, + onError: () => { + toast.error(NOTES_SAVE_FAILED_MESSAGE); + }, + }); +} diff --git a/src/domain/vendors/utils/build-company-notes-patch.ts b/src/domain/vendors/utils/build-company-notes-patch.ts new file mode 100644 index 00000000..3546a3cc --- /dev/null +++ b/src/domain/vendors/utils/build-company-notes-patch.ts @@ -0,0 +1,35 @@ +import type { VendorCompanyRoster } from "@/domain/vendors/types/vendor"; +import type { VendorRosterAdditivePatch } from "@/domain/vendors/mappers/vendor-roster-mapper"; + +export type VendorCompanyNotesBaseline = Pick< + VendorCompanyRoster, + "companyId" | "rowVersion" | "notes" +>; + +export interface VendorCompanyNotesUpdate { + companyId: string | number; + patch: VendorRosterAdditivePatch; +} + +/** + * Company-level notes edited outside the Vendors page (e.g. the Create WO wizard) + * are written through the additive roster PATCH, which only touches the fields sent. + * Returns null when nothing changed. Clearing notes is not expressible on that PATCH + * (blank company fields mean "unchanged"), so an emptied field is not sent. + */ +export function buildCompanyNotesUpdate( + baseline: VendorCompanyNotesBaseline | null | undefined, + editedNotes: string, +): VendorCompanyNotesUpdate | null { + if (!baseline || baseline.companyId === null || !baseline.rowVersion) { + return null; + } + const notes = editedNotes.trim(); + if (!notes || notes === baseline.notes.trim()) { + return null; + } + return { + companyId: baseline.companyId, + patch: { rowVersion: baseline.rowVersion, addTechnicians: [], companyFields: { notes } }, + }; +} diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index e2ae7411..54b333ed 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -269,6 +269,20 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M })); } +/** + * `GET workorders/{id}/detail` info serializes the board row's `MediaCount` as 0 — the detail + * DTO inherits the field but the service never sets it — so trusting it verbatim would leave the + * slide-over closability gate reporting "At least 1 photo" even after an upload. Take the larger + * of the reported count and the stored (non-legacy) attachments: a positive API count still wins, + * but a spurious (or omitted) 0 can no longer mask media that are actually present. + */ +function resolveDetailMediaCount(infoRaw: unknown, media: WorkOrderBoardMediaItem[]): number { + const record = asRecord(infoRaw); + const explicit = record.mediaCount ?? record.MediaCount; + const nonLegacyCount = media.filter((item) => !item.isLegacy).length; + return Math.max(typeof explicit === "number" ? explicit : 0, nonLegacyCount); +} + export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { const root = asRecord(raw); const infoRaw = root.info ?? root.Info ?? root; @@ -283,8 +297,10 @@ export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { : []; const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : []; + const info = mapWorkOrderTableRow(infoRaw); + return { - info: mapWorkOrderTableRow(infoRaw), + info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, media) }, completion: mapCompletion(completionRaw ?? {}), comments, audit, diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index e9ad34f9..c187aee8 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -32,6 +32,7 @@ export interface WorkOrderAdvancedFilters { types: Set; severities: Set; dispatchers: Set; + regions: Set; statuses: Set; rescheduled: boolean; carriedOver: boolean; @@ -55,6 +56,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { types: new Set(), severities: new Set(), dispatchers: new Set(), + regions: new Set(), statuses: new Set(), rescheduled: false, carriedOver: false, @@ -97,6 +99,7 @@ export type AdvancedFilterSetKey = | "sites" | "types" | "dispatchers" + | "regions" | "statuses" | "pmTypes" | "vendorTechs" diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index b7f2693a..ef12e1ba 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -290,6 +290,8 @@ export interface WorkOrderBoardSearchQueryParams { /** Past-due filter; independent of WorkOrderType (Other remains types=99). */ overdue?: boolean; dispatchers?: string[]; + /** SH-348: region names (East/Central/West/California/Unmapped/Other). */ + regions?: string[]; statuses?: number[]; pmTypes?: string[]; vendorIds?: number[]; diff --git a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts index 4aa4c8b8..5e4ad7c9 100644 --- a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts +++ b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts @@ -13,19 +13,19 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order import { queryKeys } from "@/infra/query-key/query-key"; export function useCreateWorkOrderFromWizard( - onSuccess?: () => void, + onSuccess?: (draft: WorkOrderWizardDraft) => void, ): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ mutationFn: (draft: WorkOrderWizardDraft) => workOrdersApi.createFromBoard(draft), - onSuccess: () => { + onSuccess: (_created, draft) => { void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); toast.success("Work Order created successfully!"); - onSuccess?.(); + onSuccess?.(draft); }, onError: (error: Error) => { diff --git a/src/domain/work-orders/use-cases/use-work-order-detail.ts b/src/domain/work-orders/use-cases/use-work-order-detail.ts index a8b3ef3f..fc28b8fd 100644 --- a/src/domain/work-orders/use-cases/use-work-order-detail.ts +++ b/src/domain/work-orders/use-cases/use-work-order-detail.ts @@ -27,11 +27,13 @@ export function useWorkOrderDetail( export function useWorkOrderBoardDetail( id: string | number | undefined, enabled = true, + meta?: { suppressErrorToast?: boolean }, ): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.boardDetail(id ?? ""), queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")), enabled: enabled && id !== undefined && id !== "", + meta, // Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over. }); } diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index ec9cd3cc..3a94e733 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -24,12 +24,14 @@ function invalidateUpliftQueries( export function useWorkOrderUplifts( workOrderId: string | number | null | undefined, + meta?: { suppressErrorToast?: boolean }, ): UseQueryResult { return useQuery({ queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""), queryFn: () => workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")), enabled: workOrderId != null && workOrderId !== "", + meta, }); } diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index 57bdab98..edc707a0 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -108,6 +108,15 @@ export function appendSetChips( nameOf: (v) => v, clear: (current) => ({ ...current, types: new Set() }), }); + + pushSetChip({ + chips, + key: "regions", + group: "Region", + set: filters.regions, + nameOf: (v) => v, + clear: (current) => ({ ...current, regions: new Set() }), + }); } export function appendSeverityChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 97e503f2..3f8f5ce5 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -26,6 +26,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte types: new Set(filters.types), severities: new Set(filters.severities ?? []), dispatchers: new Set(filters.dispatchers), + regions: new Set(filters.regions ?? []), statuses: new Set(filters.statuses), pmTypes: new Set(filters.pmTypes), vendorTechs: new Set(filters.vendorTechs), @@ -42,6 +43,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool Boolean(filters.types?.size), Boolean(filters.severities?.size), Boolean(filters.dispatchers?.size), + Boolean(filters.regions?.size), Boolean(filters.statuses?.size), filters.rescheduled, filters.carriedOver, diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 29780d1a..c10519b8 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -217,6 +217,7 @@ export function toBoardSearchQueryParams(input: { types: typeFilters.types, overdue: typeFilters.overdue, dispatchers: mapDispatchers(filters.dispatchers), + regions: filters.regions.size > 0 ? [...filters.regions] : undefined, statuses: mapStatuses(filters.statuses), pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined, vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName), diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts new file mode 100644 index 00000000..bb868e51 --- /dev/null +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -0,0 +1,123 @@ +import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; +import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; +import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"]; + +// SH-348: the board region filter (backend#146) accepts these region names. +const BOARD_REGION_NAMES = ["East", "Central", "West", "California", "Unmapped/Other"]; + +const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [ + "pending", + "approved", + "auto_approved", + "rejected", +]; + +function parseList(raw: string | null): string[] { + if (!raw) { + return []; + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter((item) => item !== ""); +} + +function parseStatuses(raw: string | null): WizardWOStatus[] { + const valid = new Set(ALL_WIZARD_STATUSES); + return [...new Set(parseList(raw))].filter((status): status is WizardWOStatus => + valid.has(status), + ); +} + +function parseTypes(raw: string | null): WOType[] { + const valid = new Set(WO_TYPES); + return [...new Set(parseList(raw))].filter((type): type is WOType => valid.has(type)); +} + +function parseCompletionDocs(raw: string | null): CompletionDocFilter[] { + const valid = new Set(COMPLETION_DOC_FILTERS); + return [...new Set(parseList(raw))].filter((doc): doc is CompletionDocFilter => valid.has(doc)); +} + +function parseRegions(raw: string | null): string[] { + const valid = new Set(BOARD_REGION_NAMES); + return [...new Set(parseList(raw))].filter((region) => valid.has(region)); +} + +function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { + const valid = new Set(UPLIFT_FILTER_VALUES); + return [...new Set(parseList(raw))].filter((value): value is UpliftFilterStatus => + valid.has(value), + ); +} + +export function parseDashboardDrilldownParams( + params: URLSearchParams, +): WorkOrderAdvancedFilters | null { + const dateFrom = (params.get("dateFrom") ?? "").trim(); + const dateTo = (params.get("dateTo") ?? "").trim(); + const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); + const avetaOnly = params.get("aveta") === "1"; + const docs = parseCompletionDocs(params.get("docs")); + const statuses = parseStatuses(params.get("statuses")); + const types = parseTypes(params.get("types")); + const dispatchers = parseList(params.get("dispatchers")); + const regions = parseRegions(params.get("regions")); + const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses")); + const hasUplift = params.get("hasUplift") === "1"; + + const hasAny = [ + hasDateRange, + avetaOnly, + docs.length, + statuses.length, + types.length, + dispatchers.length, + regions.length, + upliftStatuses.length, + hasUplift, + ].some(Boolean); + + if (!hasAny) { + return null; + } + + const filters = cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS); + + if (hasDateRange) { + filters.dateRange = "custom"; + filters.customFrom = dateFrom; + filters.customTo = dateTo; + } + filters.avetaOnly = avetaOnly; + filters.hasUplift = hasUplift || upliftStatuses.length > 0; + if (docs.length > 0) { + filters.docs = new Set(docs); + } + if (statuses.length > 0) { + filters.statuses = new Set(statuses); + } + if (types.length > 0) { + filters.types = new Set(types); + } + if (dispatchers.length > 0) { + filters.dispatchers = new Set(dispatchers); + } + if (regions.length > 0) { + filters.regions = new Set(regions); + } + if (upliftStatuses.length > 0) { + filters.upliftStatuses = new Set(upliftStatuses); + } + + return filters; +} diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 4f2a045b..3d8eb132 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -10,6 +10,7 @@ import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced- import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display"; import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; +import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]); @@ -17,8 +18,10 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean { if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) { return false; } - const today = new Date().toISOString().slice(0, 10); - return row.scheduledOn.slice(0, 10) < today; + // scheduledOn is a calendar date, so compare it to the user's local calendar + // day. A UTC "today" rolls over hours early west of UTC and flags work + // scheduled for today as Past Due every evening. + return row.scheduledOn.slice(0, 10) < todayIso(); } export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow { diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index e094cf8b..d5234338 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -1,3 +1,5 @@ +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardTrendParams } from "@/domain/dashboard/types/dashboard-trend"; import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderBoardQueryParams, @@ -50,10 +52,21 @@ export const queryKeys = { teamMembers: { all: ["teamMembers"] as const, list: () => [...queryKeys.teamMembers.all, "list"] as const, + detail: (id: string | number) => [...queryKeys.teamMembers.all, "detail", id] as const, }, dashboard: { all: ["dashboard"] as const, - stats: () => [...queryKeys.dashboard.all, "stats"] as const, + stats: (range: DashboardRangeKey, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "stats", range, dispatcherId] as const, + workload: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "workload", range, page, dispatcherId] as const, + performance: (range: DashboardRangeKey, page = 1, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "performance", range, page, dispatcherId] as const, + regions: (range: DashboardRangeKey, dispatcherId: string | null = null) => + [...queryKeys.dashboard.all, "regions", range, dispatcherId] as const, + vendorInsights: () => [...queryKeys.dashboard.all, "vendorInsights"] as const, + trend: (params?: DashboardTrendParams) => + [...queryKeys.dashboard.all, "trend", params ?? {}] as const, }, dropdownOptions: { all: ["dropdownOptions"] as const, @@ -104,6 +117,7 @@ export const queryKeys = { forDispatch: (dispatchId: string | number) => [...queryKeys.uplifts.all, "dispatch", dispatchId] as const, evidence: (id: string | number) => [...queryKeys.uplifts.all, "evidence", id] as const, + canApprove: (tier: number) => [...queryKeys.uplifts.all, "can-approve", tier] as const, }, vendorPos: { all: ["vendorPos"] as const, diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts index 55ca7411..1fcd3f4d 100644 --- a/src/lib/auth/user-utils.ts +++ b/src/lib/auth/user-utils.ts @@ -14,3 +14,18 @@ export function isAdminUser(userRoles: string | null | undefined): boolean { .map((role) => role.trim().toLowerCase()) .includes("admin"); } + +/** + * SH-336's `viewAllDispatchersOnDashboard` permission. The codebase has no + * permission model, so it is derived from role: Admin and Scheduler may pick any + * dispatcher (or the whole company) on the Dashboard; a Dispatcher may not and + * is locked to their own work orders. Unknown roles default to the restrictive + * (Dispatcher) behaviour so no one accidentally gains a company-wide view. + */ +export function canViewAllDispatchersOnDashboard(userRoles: string | null | undefined): boolean { + if (!userRoles) { + return false; + } + const roles = userRoles.split(",").map((role) => role.trim().toLowerCase()); + return roles.includes("admin") || roles.includes("scheduler"); +} diff --git a/src/lib/query/query-client.ts b/src/lib/query/query-client.ts index 1223fc2b..6c321caf 100644 --- a/src/lib/query/query-client.ts +++ b/src/lib/query/query-client.ts @@ -1,33 +1,39 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; -export const queryClient = new QueryClient({ - queryCache: new QueryCache({ - onError: (error) => { - console.error("Query error:", error); - const message = - error instanceof Error ? error.message : "An error occurred while fetching data"; - toast.error(message); - }, - }), - mutationCache: new MutationCache({ - onError: (error, _variables, _context, mutation) => { - if (mutation.options.meta?.suppressErrorToast) return; +export function createAppQueryClient(): QueryClient { + return new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (query.meta?.suppressErrorToast) return; - console.error("Mutation error:", error); - const message = - error instanceof Error ? error.message : "An error occurred while updating data"; - toast.error(message); + console.error("Query error:", error); + const message = + error instanceof Error ? error.message : "An error occurred while fetching data"; + toast.error(message); + }, + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + if (mutation.options.meta?.suppressErrorToast) return; + + console.error("Mutation error:", error); + const message = + error instanceof Error ? error.message : "An error occurred while updating data"; + toast.error(message); + }, + }), + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, }, - }), - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); + }); +} + +export const queryClient = createAppQueryClient(); export default queryClient; diff --git a/src/test/api/api-formdata-content-type.test.ts b/src/test/api/api-formdata-content-type.test.ts index b914b9f0..94312aba 100644 --- a/src/test/api/api-formdata-content-type.test.ts +++ b/src/test/api/api-formdata-content-type.test.ts @@ -1,60 +1,90 @@ +// @vitest-environment node +// Node's fetch/FormData/Request follow the WHATWG rules a browser uses to derive the multipart +// Content-Type; jsdom's FormData does not, which is how a header-less upload once passed here. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { shouldStripJsonContentType } from "@/api/api"; -describe("shouldStripJsonContentType", () => { - it("returns true for FormData (multipart uploads)", () => { - const form = new FormData(); - form.append("file", new File(["x"], "a.png", { type: "image/png" })); - expect(shouldStripJsonContentType(form)).toBe(true); - }); +type Captured = { method: string; contentType: string | null; body: string }; - it("returns false for JSON body / undefined", () => { - expect(shouldStripJsonContentType(undefined)).toBe(false); - expect(shouldStripJsonContentType(JSON.stringify({ a: 1 }))).toBe(false); - expect(shouldStripJsonContentType({ a: 1 })).toBe(false); - }); -}); +const API_URL = "http://api.test/api"; +let captured: Captured[] = []; -describe("api.post FormData Content-Type", () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("does not send Content-Type: application/json when body is FormData", async () => { - let capturedContentType: string | null = null; - - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const headers = new Headers( - init?.headers ?? (input instanceof Request ? input.headers : undefined), - ); - capturedContentType = headers.get("Content-Type"); - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, +beforeEach(() => { + captured = []; + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } })); + vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + // Serialize exactly what would go on the wire. + const request = new Request(input, init); + captured.push({ + method: request.method, + contentType: request.headers.get("content-type"), + body: await request.text(), }); - }) as typeof fetch; + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }), + ); +}); - const { api } = await import("@/api/api"); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function lastRequest(): Captured { + const request = captured[captured.length - 1]; + if (!request) throw new Error("No request reached fetch"); + return request; +} + +describe("api multipart uploads", () => { + it("sends FormData with a multipart Content-Type that carries the body's boundary", async () => { + const { apiRequestRaw } = await import("@/api/api"); const form = new FormData(); - form.append("category", "Before"); - form.append("file", new File(["png"], "image.png", { type: "image/png" })); + form.append("file", new File(["%PDF-1.7"], "signed.pdf", { type: "application/pdf" })); + form.append("workOrderVersion", "AAAAAAAAB9E="); - await api.post("workorders/9/media", { body: form, throwHttpErrors: false }); + await apiRequestRaw("post", "workorders/10/completion-doc", { + body: form, + throwHttpErrors: false, + }); - expect(capturedContentType).not.toBe("application/json"); - // Browser sets multipart/form-data; boundary=... when Content-Type was stripped. - // In jsdom/fetch mock the runtime may leave it null or set multipart - both are OK - // as long as it is not forced JSON. - // Cast after expect(): Vitest assertion narrowing collapses string|null to never. - const contentType = capturedContentType as string | null; - if (contentType !== null) { - expect(contentType.toLowerCase()).toContain("multipart/form-data"); - } + const request = lastRequest(); + const boundary = /^multipart\/form-data; boundary=(.+)$/.exec(request.contentType ?? "")?.[1]; + expect(boundary, `Content-Type was ${String(request.contentType)}`).toBeTruthy(); + expect(request.body).toContain(`--${boundary}`); + expect(request.body).toContain('name="file"; filename="signed.pdf"'); + }); + + it("sends the multipart Content-Type for PATCH category updates and apiPostForm", async () => { + const { apiRequestRaw, apiPostForm } = await import("@/api/api"); + const category = new FormData(); + category.append("category", "Before"); + + await apiRequestRaw("patch", "workorders/10/media/5", { + body: category, + throwHttpErrors: false, + }); + expect(lastRequest().contentType).toMatch(/^multipart\/form-data; boundary=/); + + const edit = new FormData(); + edit.append("Id", "10"); + await apiPostForm("WorkOrder/EditWorkorder", edit); + expect(lastRequest().contentType).toMatch(/^multipart\/form-data; boundary=/); + }); + + it("keeps application/json for json bodies", async () => { + const { apiPost, apiPatch } = await import("@/api/api"); + + await apiPost("workorders/10/comments", { text: "hello" }); + expect(lastRequest()).toMatchObject({ + method: "POST", + contentType: "application/json", + body: '{"text":"hello"}', + }); + + await apiPatch("workorders/10/board", { field: "docStatus", value: "Yes" }); + expect(lastRequest().contentType).toBe("application/json"); }); }); diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx new file mode 100644 index 00000000..5ad7396e --- /dev/null +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -0,0 +1,265 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { renderWithProviders } from "@/test/test-utils"; + +const navigate = vi.fn(); +const statsRange = vi.fn<(range: DashboardRangeKey) => void>(); +const { workloadMock, performanceMock, authUser } = vi.hoisted(() => ({ + workloadMock: vi.fn(), + performanceMock: vi.fn(), + authUser: { current: { id: "99", userRoles: "Admin" } as { id: string; userRoles: string } }, +})); + +const EMPTY_DISPATCHER_PAGE = { items: [], page: 1, pageSize: 10, totalDispatchers: 0 }; + +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, +})); + +// An Admin has `viewAllDispatchersOnDashboard`, so the scope picker and the +// dispatcher tables render (SH-336). +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: authUser.current }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false, isFetching: false, error: null }), +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-workload", () => ({ + useDashboardWorkload: workloadMock, +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-performance", () => ({ + useDashboardPerformance: performanceMock, +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-regions", () => ({ + useDashboardRegions: () => ({ data: [], isLoading: false, isFetching: false, error: null }), +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-vendor-insights", () => ({ + useDashboardVendorInsights: () => ({ + data: [], + isLoading: false, + isFetching: false, + error: null, + }), +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-trend", () => ({ + useDashboardTrend: () => ({ + data: { granularity: null, points: [] }, + isLoading: false, + isFetching: false, + error: null, + }), +})); + +const statsData = { + scheduledTomorrow: 3, + pendingUplifts: 5, + avetaPending: 0, + breakdown: [ + { status: "PM", count: 7 }, + { status: "Completed", count: 0 }, + ], +}; + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-stats", () => ({ + useDashboardStats: (range: DashboardRangeKey) => { + statsRange(range); + return { data: statsData, isLoading: false, isFetching: false, error: null }; + }, +})); + +import DashboardPage from "@/app/(protected)/dashboard"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils"; + +function renderDashboard() { + return renderWithProviders(); +} + +describe("DashboardPage", () => { + beforeEach(() => { + authUser.current = { id: "99", userRoles: "Admin" }; + navigate.mockReset(); + statsRange.mockReset(); + workloadMock.mockReset(); + performanceMock.mockReset(); + workloadMock.mockReturnValue({ + data: EMPTY_DISPATCHER_PAGE, + isLoading: false, + isFetching: false, + error: null, + }); + performanceMock.mockReturnValue({ + data: EMPTY_DISPATCHER_PAGE, + isLoading: false, + isFetching: false, + error: null, + }); + }); + + it("renders the prototype title, range options, and card titles", () => { + renderDashboard(); + + expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); + for (const label of ["This week", "Last 3 months", "Today", "All time"]) { + expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); + } + for (const title of [ + "Scheduled Tomorrow", + "Pending Uplifts", + "Aveta Pending", + "Work Order Breakdown", + "Status Distribution", + "Dispatcher Workload", + "Dispatcher Performance", + "Work Orders by Region", + "Vendor Insights", + ]) { + expect(screen.getByText(title)).toBeInTheDocument(); + } + expect(statsRange).toHaveBeenCalledWith("this-week"); + }); + + it("shows a Dispatcher the static My WOs scope and hides the dispatcher tables", () => { + authUser.current = { id: "7", userRoles: "Dispatcher" }; + + renderDashboard(); + + expect(screen.getByText("My WOs")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "All dispatchers" })).toBeNull(); + expect(screen.queryByText("Dispatcher Workload")).toBeNull(); + expect(screen.queryByText("Dispatcher Performance")).toBeNull(); + // Their scope is server-scoped, so the tables are never even queried. + expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, false); + }); + + it("lets an Admin open the dispatcher-scope picker", () => { + renderDashboard(); + + expect(screen.getByRole("button", { name: /All dispatchers/ })).toBeInTheDocument(); + expect(screen.getByText("Dispatcher Workload")).toBeInTheDocument(); + expect(workloadMock).toHaveBeenCalledWith("this-week", 1, null, true); + }); + + it("exposes positive KPI cards as keyboard-accessible drill-downs", () => { + renderDashboard(); + + const tomorrowCard = screen.getByRole("button", { name: /Scheduled Tomorrow/ }); + expect(tomorrowCard).toBeInTheDocument(); + fireEvent.click(tomorrowCard); + + const today = businessTodayIso(); + expect(navigate).toHaveBeenCalledWith( + `/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`, + ); + + fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ })); + expect(navigate).toHaveBeenCalledWith("/uplifts"); + }); + + it("keeps zero counts inert", () => { + renderDashboard(); + + expect(screen.queryByRole("button", { name: /Aveta Pending/ })).toBeNull(); + + fireEvent.click(screen.getByText("Aveta Pending")); + expect(navigate).not.toHaveBeenCalled(); + + const inertRow = screen.getByText("Completed"); + fireEvent.click(inertRow); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("drills the breakdown status into filtered work orders", () => { + renderDashboard(); + + fireEvent.click(screen.getByText("PM")); + + const calls = navigate.mock.calls; + const [url] = calls[calls.length - 1] ?? []; + expect(String(url)).toContain("/workorders?"); + expect(String(url)).toContain("types=PM"); + }); + + it("switches the stats range from the toggle", () => { + renderDashboard(); + + fireEvent.click(screen.getByRole("button", { name: "Today" })); + + expect(statsRange).toHaveBeenCalledWith("today"); + }); + + it("shows the documented empty states when datasets are empty", () => { + renderDashboard(); + + // Region, Status Distribution, and Trend share this empty copy. + expect(screen.getAllByText("No work orders match the current filters.")).toHaveLength(3); + expect(screen.getAllByText("No dispatchers are set up yet.")).toHaveLength(2); + expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument(); + }); + + it("pages the dispatcher tables at 10 rows and re-queries on page change", () => { + const rows = Array.from({ length: 10 }, (_, index) => ({ + dispatcherId: String(index + 1), + dispatcherName: `Dispatcher ${index + 1}`, + openWorkOrders: 1, + totalWorkOrders: 2, + })); + workloadMock.mockReturnValue({ + data: { items: rows, page: 1, pageSize: 10, totalDispatchers: 23 }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderDashboard(); + + // 23 dispatchers at 10/page = 3 pages; selecting page 2 re-queries with page 2. + fireEvent.click(screen.getByRole("button", { name: "Go to page 2" })); + + const requestedPages = workloadMock.mock.calls.map((call) => call[1]); + expect(requestedPages).toContain(2); + }); + + it("resets the dispatcher page when the range changes", () => { + const rows = Array.from({ length: 10 }, (_, index) => ({ + dispatcherId: String(index + 1), + dispatcherName: `Dispatcher ${index + 1}`, + openWorkOrders: 1, + totalWorkOrders: 2, + })); + workloadMock.mockReturnValue({ + data: { items: rows, page: 2, pageSize: 10, totalDispatchers: 23 }, + isLoading: false, + isFetching: false, + error: null, + }); + + renderDashboard(); + + fireEvent.click(screen.getByRole("button", { name: "Go to page 2" })); + fireEvent.click(screen.getByRole("button", { name: "Today" })); + + const calls = workloadMock.mock.calls; + const lastRequestedPage = calls[calls.length - 1]?.[1]; + expect(lastRequestedPage).toBe(1); + }); + + it("keeps drilldown dates deterministic on the business clock", () => { + const today = businessTodayIso(); + + expect(scheduledTomorrowDrilldownSearch(today).get("dateFrom")).toBe(addDaysIso(today, 1)); + expect(avetaPendingDrilldownSearch(today).get("aveta")).toBe("1"); + }); +}); diff --git a/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx b/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx new file mode 100644 index 00000000..4e011bd6 --- /dev/null +++ b/src/test/app/(protected)/dashboard/dispatcher-table-pager.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DispatcherTablePager } from "@/app/(protected)/dashboard/_components/dispatcher-table-pager"; + +describe("DispatcherTablePager", () => { + it("renders nothing when everything fits on one page", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders a page per 10 rows and reports the selected page", () => { + const onPageChange = vi.fn(); + render( + , + ); + + // 23 rows at 10/page = 3 pages. + expect(screen.getByRole("button", { name: "Go to page 3" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Go to page 4" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Go to page 2" })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); +}); diff --git a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx index f26b9ea5..efb56316 100644 --- a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx +++ b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx @@ -1,6 +1,9 @@ import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import PmSchedulesListPage from "@/app/(protected)/pmschedules/index"; +// Static import: a dynamic import inside the test transforms the edit page's +// module graph against the 5s test budget, which times out under load. +import EditPage from "@/app/(protected)/pmschedules/edit/[id]"; import { renderWithProviders } from "@/test/test-utils"; const usePmSchedulesList = vi.fn(); @@ -75,9 +78,7 @@ describe("PmSchedulesListPage actions", () => { }); describe("PmScheduleEditUnavailablePage", () => { - it("shows unavailable message instead of edit form when canEdit is false", async () => { - const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default; - + it("shows unavailable message instead of edit form when canEdit is false", () => { renderWithProviders(, { route: "/pmschedules/edit/1", routerProps: { initialEntries: ["/pmschedules/edit/1"] }, diff --git a/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx b/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx new file mode 100644 index 00000000..e6dc25b1 --- /dev/null +++ b/src/test/app/(protected)/team-members/team-member-detail-drawer.test.tsx @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/test/test-utils"; +import { TeamMemberDetailDrawer } from "@/app/(protected)/team-members/_components/team-member-detail-drawer"; +import { + useTeamMemberDetail, + useUpdateTeamMember, +} from "@/domain/team-members/use-cases/use-team-member-detail"; +import type { TeamMemberDetail, TeamMemberListItem } from "@/domain/team-members/types/team-member"; + +vi.mock("@/domain/team-members/use-cases/use-team-member-detail", () => ({ + useTeamMemberDetail: vi.fn(), + useUpdateTeamMember: vi.fn(), +})); + +const member: TeamMemberListItem = { + id: "u-1", + name: "Taylor Dispatcher", + role: "Dispatcher", + serviceArea: "East", + phone: "555-0100", + email: "taylor@example.com", + color: "#F59E0B", + status: "Active", + isAccountOwner: false, + pendingRegistration: true, +}; + +const detail: TeamMemberDetail = { + ...member, + serviceAreas: ["East"], + permissions: [ + { permissionKey: "createSites", overrideState: 1, isGranted: true }, + { permissionKey: "editSites", overrideState: 2, isGranted: false }, + ], +}; + +describe("TeamMemberDetailDrawer", () => { + const mutate = vi.fn(); + + beforeEach(() => { + mutate.mockReset(); + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: detail, + isLoading: false, + error: null, + } as unknown as ReturnType); + vi.mocked(useUpdateTeamMember).mockReturnValue({ + mutate, + isPending: false, + } as unknown as ReturnType); + }); + + it("shows all member fields and grouped permission state in view mode", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0); + expect(screen.getByText("taylor@example.com")).toBeInTheDocument(); + expect(screen.getByText("East")).toBeInTheDocument(); + expect(screen.getByText("Pending registration")).toBeInTheDocument(); + expect(screen.getByText("Create sites")).toBeInTheDocument(); + expect(screen.getByText("Granted")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + }); + + it("edits fields and saves active status and permission overrides", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Taylor Updated"); + await user.click(screen.getByRole("switch", { name: "Active" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "u-1", + input: expect.objectContaining({ + name: "Taylor Updated", + isActive: false, + serviceAreas: ["East"], + }), + }), + expect.any(Object), + ); + }); + + it("disables role changes and deactivation for the account owner", async () => { + const owner = { ...detail, isAccountOwner: true }; + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: owner, + isLoading: false, + error: null, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + expect(screen.getByRole("combobox", { name: "Role" })).toHaveAttribute("aria-disabled", "true"); + const activeSwitch = screen.getByRole("switch", { name: "Active" }); + expect(activeSwitch).toBeDisabled(); + const tooltipTarget = activeSwitch.closest("label")?.parentElement; + if (!tooltipTarget) throw new Error("missing account-owner tooltip target"); + await user.hover(tooltipTarget); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + "The account owner cannot be deactivated.", + ), + ); + }); + + it("keeps edits when the detail query refreshes", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithProviders( + , + { withAuth: false }, + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Taylor Updated"); + + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, phone: "555-0101" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + rerender(); + + expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Taylor Updated"); + expect(screen.getByRole("textbox", { name: "Phone" })).toHaveValue("555-0100"); + }); + + it("restores the saved detail when editing is cancelled", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const name = screen.getByRole("textbox", { name: "Name" }); + await user.clear(name); + await user.type(name, "Discarded draft"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(screen.getAllByText("Taylor Dispatcher")).not.toHaveLength(0); + expect(screen.queryByDisplayValue("Discarded draft")).not.toBeInTheDocument(); + }); + + it("marks a legacy role in view mode", () => { + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, role: "Manager" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Manager (legacy)")).toBeInTheDocument(); + }); + + it("shows a legacy role in the edit form and blocks save until a supported role is chosen", async () => { + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, role: "Manager" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const roleSelect = screen.getByRole("combobox", { name: "Role" }); + expect(roleSelect).toHaveTextContent("Manager (legacy)"); + expect( + screen.getByText(/Manager is a legacy role and can no longer be saved/), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(mutate).not.toHaveBeenCalled(); + + await user.click(roleSelect); + await user.click(screen.getByRole("option", { name: "Scheduler" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: "u-1", + input: expect.objectContaining({ role: "Scheduler" }), + }), + expect.any(Object), + ); + }); + + it("preselects the nearest palette color for a legacy color and saves without a manual pick", async () => { + vi.mocked(useTeamMemberDetail).mockReturnValue({ + data: { ...detail, color: "#FF0000" }, + isLoading: false, + error: null, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(, { + withAuth: false, + }); + + await user.click(screen.getByRole("button", { name: "Edit" })); + expect( + screen.getByText(/Saved color #FF0000 is not in the current palette/), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select color #EA580C" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ color: "#EA580C" }), + }), + expect.any(Object), + ); + }); +}); diff --git a/src/test/app/(protected)/team-members/team-member-form-options.test.ts b/src/test/app/(protected)/team-members/team-member-form-options.test.ts new file mode 100644 index 00000000..8d9dac7f --- /dev/null +++ b/src/test/app/(protected)/team-members/team-member-form-options.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalRole, + nearestPaletteColor, + resolveFormColor, + TEAM_MEMBER_COLORS, +} from "@/app/(protected)/team-members/_components/team-member-form-options"; + +describe("canonicalRole", () => { + it("returns the canonical role for supported values", () => { + expect(canonicalRole("Dispatcher")).toBe("Dispatcher"); + expect(canonicalRole("scheduler")).toBe("Scheduler"); + expect(canonicalRole(" Admin ")).toBe("Admin"); + }); + + it("returns null for legacy and empty roles", () => { + expect(canonicalRole("Manager")).toBeNull(); + expect(canonicalRole("Supervisor")).toBeNull(); + expect(canonicalRole("")).toBeNull(); + }); +}); + +describe("nearestPaletteColor", () => { + it("returns the closest palette color by RGB distance", () => { + expect(nearestPaletteColor("#FF0000")).toBe("#EA580C"); + expect(nearestPaletteColor("#00FF00")).toBe("#16A34A"); + expect(nearestPaletteColor("#111111")).toBe("#16A34A"); + }); + + it("falls back to the first palette color for unparseable values", () => { + expect(nearestPaletteColor("not-a-color")).toBe(TEAM_MEMBER_COLORS[0]); + expect(nearestPaletteColor("")).toBe(TEAM_MEMBER_COLORS[0]); + }); +}); + +describe("resolveFormColor", () => { + it("keeps palette colors and canonicalizes their casing", () => { + expect(resolveFormColor("#F59E0B")).toEqual({ color: "#F59E0B", notice: "" }); + expect(resolveFormColor("#f59e0b")).toEqual({ color: "#F59E0B", notice: "" }); + }); + + it("replaces a legacy color with the nearest palette color and explains the replacement", () => { + const resolved = resolveFormColor("#FF0000"); + expect(resolved.color).toBe("#EA580C"); + expect(resolved.notice).toContain("#FF0000"); + expect(resolved.notice).toContain("#EA580C"); + }); + + it("preselects the first palette color when nothing was stored", () => { + const resolved = resolveFormColor(" "); + expect(resolved.color).toBe(TEAM_MEMBER_COLORS[0]); + expect(resolved.notice).not.toBe(""); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx new file mode 100644 index 00000000..7b3456a8 --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const revokeMutate = vi.fn(); + +const queueState = vi.hoisted(() => ({ + data: null as UpliftsQueueResult | null, +})); +const authState = vi.hoisted(() => ({ + userRoles: "Admin", +})); + +const approvedItem: UpliftQueueItem = { + id: 77, + status: "Approved", + currentNTE: 500, + requestedNTE: 900, + delta: 400, + vendorReason: "Additional scope", + requestedAt: "2026-01-10T08:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "2026-01-12T09:30:00Z", + decidedByName: "Admin User", + decisionNote: "", + requiredTier: 1, + canDecide: false, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: "doc-1", + evidenceFileName: "quote.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 2048, + dispatchNumber: "DSP-77", + poNumber: "PO-77", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 55, + dispatchId: 12, + woNumber: "WO-55", + site: "Site B", + serviceName: "HVAC service", + attachmentCount: 3, + approvedOnWoAuto: 100, + approvedOnWoAdmin: 400, + approvedOnWoTotal: 500, + workOrderClosed: true, +}; + +const approvedOpenItem: UpliftQueueItem = { + ...approvedItem, + id: 78, + woNumber: "WO-56", + workOrderClosed: false, +}; + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: () => ({ + data: queueState.data, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: authState.userRoles } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +describe("Uplift Approvals approved tab", () => { + beforeEach(() => { + revokeMutate.mockReset(); + authState.userRoles = "Admin"; + queueState.data = { + items: [approvedItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + }); + + it("disables Revoke with the closed work order tooltip", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument(); + expect(screen.getByText("Admin User")).toBeInTheDocument(); + const revoke = await screen.findByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.mouseOver(revoke); + expect( + await screen.findByText("This work order is closed. Uplifts can no longer be revoked."), + ).toBeInTheDocument(); + expect(revokeMutate).not.toHaveBeenCalled(); + }); + + it("opens RevokeDialog for an approved uplift on an open work order", async () => { + queueState.data = { + items: [approvedOpenItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + const revoke = await screen.findByRole("button", { name: "Revoke" }); + expect(revoke).toBeEnabled(); + fireEvent.click(revoke); + expect(screen.getByRole("heading", { name: "Revoke this approval?" })).toBeInTheDocument(); + expect( + screen.getByText( + "The approved uplift of $900.00 on WO WO-56 will be withdrawn. This does not recover money already spent — it records that the authorization was a mistake.", + ), + ).toBeInTheDocument(); + }); + + it("disables Revoke with the admin-only tooltip for a Dispatcher and never opens the dialog", async () => { + authState.userRoles = "Dispatcher"; + queueState.data = { + items: [approvedOpenItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + const revoke = await screen.findByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.mouseOver(revoke); + expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument(); + fireEvent.click(revoke); + expect( + screen.queryByRole("heading", { name: "Revoke this approval?" }), + ).not.toBeInTheDocument(); + expect(revokeMutate).not.toHaveBeenCalled(); + }); + + it("shows the approved empty copy when nothing is archived", async () => { + queueState.data = { + items: [], + totalCount: 0, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument(); + expect( + screen.getByText("Uplifts you approve are archived here for reference."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal-permissions.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal-permissions.test.tsx new file mode 100644 index 00000000..2b65b7ca --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal-permissions.test.tsx @@ -0,0 +1,132 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; +import { ApiError } from "@/api/api-error"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { createAppQueryClient } from "@/lib/query/query-client"; +import { renderWithProviders } from "@/test/test-utils"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); +const boardDetailMock = vi.hoisted(() => ({ calls: 0 })); +const woUpliftsMock = vi.hoisted(() => ({ calls: 0 })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error }, +})); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + getBoardDetail: vi.fn(() => { + boardDetailMock.calls += 1; + return Promise.reject( + new ApiError("You do not have permission to perform this action.", 403), + ); + }), + }, +})); + +vi.mock("@/domain/work-orders/api/work-order-uplifts-api", () => ({ + workOrderUpliftsApi: { + list: vi.fn(() => { + woUpliftsMock.calls += 1; + return Promise.reject( + new ApiError("You do not have permission to perform this action.", 403), + ); + }), + }, +})); + +vi.mock("@/domain/auth/api/auth-api", async (importOriginal) => ({ + ...(await importOriginal()), + getSession: vi.fn(() => + Promise.resolve({ + token: "t", + expiration: "", + email: "dispatch@seahaven.test", + userRoles: "Dispatcher", + phoneNumber: "", + fullname: "Dispatch User", + id: "u1", + }), + ), +})); + +const dispatcherItem: UpliftQueueItem = { + id: 41, + status: "Approved", + currentNTE: 100, + requestedNTE: 250, + delta: 150, + vendorReason: "", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "2026-01-16T10:00:00Z", + decidedByName: "Admin User", + decisionNote: "", + requiredTier: 1, + canDecide: false, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: null, + evidenceFileName: "", + evidenceContentType: "", + evidenceSizeBytes: null, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 99, + dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +function renderWithGovernedClient() { + const queryClient = createAppQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false } }); + return renderWithProviders( + , + { queryClient }, + ); +} + +describe("UpliftDetailModal permission failures (Dispatcher 403)", () => { + beforeEach(() => { + toastMocks.error.mockReset(); + boardDetailMock.calls = 0; + woUpliftsMock.calls = 0; + }); + + it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => { + renderWithGovernedClient(); + + await waitFor(() => { + expect(boardDetailMock.calls).toBeGreaterThan(0); + expect(woUpliftsMock.calls).toBeGreaterThan(0); + }); + + await waitFor(() => { + expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3); + }); + + const revoke = screen.getByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx new file mode 100644 index 00000000..ea42e5ae --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-detail-modal.test.tsx @@ -0,0 +1,183 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { renderWithProviders } from "@/test/test-utils"; + +const boardDetail = vi.hoisted(() => ({ data: null as Record | null })); +const woUplifts = vi.hoisted(() => ({ data: null as Array> | null })); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ + useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }), +})); + +const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined })); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: canApproveState.data }), +})); + +const baseItem: UpliftQueueItem = { + id: 41, + status: "Pending", + currentNTE: 100, + requestedNTE: 250, + delta: 150, + vendorReason: "", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Gateway", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: null, + evidenceFileName: "", + evidenceContentType: "", + evidenceSizeBytes: null, + dispatchNumber: "DSP-41", + poNumber: "PO-41", + vendorCompanyName: "Gateway Plumbing", + workOrderId: 99, + dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: null, +}; + +function renderModal(overrides: Partial = {}, canRevoke = true) { + const onApprove = vi.fn(); + const onReject = vi.fn(); + const onRevoke = vi.fn(); + const onOpenAttachment = vi.fn(); + renderWithProviders( + , + ); + return { onApprove, onReject, onRevoke, onOpenAttachment }; +} + +describe("UpliftDetailModal", () => { + beforeEach(() => { + canApproveState.data = true; + boardDetail.data = { + info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" }, + }; + woUplifts.data = [ + { id: 1, status: "auto_approved", amount: 100 }, + { id: 2, status: "approved", amount: 250 }, + ]; + }); + + it("shows work order and request placeholders for a pending uplift", () => { + renderModal(); + + expect(screen.getByText("Unscheduled")).toBeInTheDocument(); + expect(screen.getByText("No justification provided.")).toBeInTheDocument(); + expect(screen.getByText("No attachments")).toBeInTheDocument(); + }); + + it("opens an evidence attachment through the provided callback", () => { + const { onOpenAttachment } = renderModal({ + evidenceDocumentId: "document-1", + evidenceFileName: "quote.pdf", + attachmentCount: 1, + }); + + fireEvent.click(screen.getByText("quote.pdf")); + expect(onOpenAttachment).toHaveBeenCalledWith( + expect.objectContaining({ evidenceDocumentId: "document-1" }), + ); + }); + + it("falls back to the work order uplifts for the approved-on-WO breakdown", () => { + renderModal(); + + expect(screen.getByText("Auto-approved")).toBeInTheDocument(); + expect(screen.getByText("Admin-approved")).toBeInTheDocument(); + expect(screen.getByText("$100.00")).toBeInTheDocument(); + expect(screen.getByText("$250.00")).toBeInTheDocument(); + expect(screen.getByText("$350.00")).toBeInTheDocument(); + }); + + it("offers status-specific actions for a pending uplift", () => { + const { onApprove, onReject } = renderModal(); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + expect(onApprove).toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + expect(onReject).toHaveBeenCalled(); + }); + + it("disables Approve and Reject in the modal for a Dispatcher the backend refuses", async () => { + canApproveState.data = false; + const { onApprove, onReject } = renderModal(); + + const approve = screen.getByRole("button", { name: "Approve" }); + const reject = screen.getByRole("button", { name: "Reject" }); + expect(approve).toBeDisabled(); + expect(reject).toBeDisabled(); + fireEvent.mouseOver(approve); + expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument(); + fireEvent.click(approve); + fireEvent.click(reject); + expect(onApprove).not.toHaveBeenCalled(); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("offers Revoke for an approved uplift and disables it on a closed work order", () => { + const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true }); + + expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument(); + const revoke = screen.getByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.click(revoke); + expect(onRevoke).not.toHaveBeenCalled(); + }); + + it("disables Revoke with the admin-only tooltip for non-admin users", async () => { + const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: false }, false); + + const revoke = screen.getByRole("button", { name: "Revoke" }); + expect(revoke).toBeDisabled(); + fireEvent.mouseOver(revoke); + expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument(); + fireEvent.click(revoke); + expect(onRevoke).not.toHaveBeenCalled(); + }); + + it("does not render a heading nested inside the dialog title heading", () => { + renderModal(); + + const dialog = screen.getByRole("dialog"); + const headings = dialog.querySelectorAll("h1, h2, h3, h4, h5, h6"); + expect(headings.length).toBeGreaterThan(0); + const nestedHeadings = [...headings].filter((heading) => + heading.querySelector("h1, h2, h3, h4, h5, h6"), + ); + expect(nestedHeadings).toEqual([]); + }); +}); diff --git a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx index afc17983..23a4d3ec 100644 --- a/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx @@ -1,13 +1,18 @@ import { fireEvent, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; import { renderWithProviders } from "@/test/test-utils"; const rejectMutate = vi.fn(); const approveMutate = vi.fn(); -const requestChangesMutate = vi.fn(); +const revokeMutate = vi.fn(); const refetch = vi.fn(); +const queueState = vi.hoisted(() => ({ + data: null as UpliftsQueueResult | null, +})); +const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined })); + const pendingItem: UpliftQueueItem = { id: 41, status: "Pending", @@ -34,11 +39,19 @@ const pendingItem: UpliftQueueItem = { vendorCompanyName: "Gateway Plumbing", workOrderId: 99, dispatchId: 7, + woNumber: "WO-99", + site: "Site A", + serviceName: "Plumbing repair", + attachmentCount: null, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: 100, + workOrderClosed: false, }; vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ useUpliftsQueue: () => ({ - data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 }, + data: queueState.data, isLoading: false, isFetching: false, error: null, @@ -49,33 +62,102 @@ vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ useApproveUplift: () => ({ isPending: false, mutate: approveMutate }), useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }), - useRequestChangesUplift: () => ({ isPending: false, mutate: requestChangesMutate }), + useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }), })); -describe("Uplift queue decision dialogs", () => { +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: canApproveState.data }), +})); + +describe("Uplift Approvals decision flow", () => { beforeEach(() => { rejectMutate.mockReset(); approveMutate.mockReset(); - requestChangesMutate.mockReset(); + revokeMutate.mockReset(); refetch.mockReset(); + canApproveState.data = true; + queueState.data = { + items: [pendingItem], + totalCount: 1, + page: 1, + pageSize: 25, + pendingExposureTotal: 250, + }; }); - it("opens RejectDialog from Reject and does not reject on Cancel", async () => { + it("shows the Uplift Approvals heading, count pill and exposure total", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Uplift Approvals" })).toBeInTheDocument(); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + expect(screen.getByText("Pending exposure: $250.00")).toBeInTheDocument(); + }); + + it("approves in one click without a confirmation dialog", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" }); + expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("opens RejectDialog with the dispatcher copy and does not reject on Cancel", async () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); fireEvent.click(screen.getByRole("button", { name: "Reject" })); expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument(); + expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(rejectMutate).not.toHaveBeenCalled(); }); - it("keeps Approve on the generic confirm dialog", async () => { + it("shows the pending empty copy when nothing is waiting", async () => { + queueState.data = { + items: [], + totalCount: 0, + page: 1, + pageSize: 25, + pendingExposureTotal: null, + }; const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("button", { name: "Approve" })); - expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Reject uplift" })).not.toBeInTheDocument(); + expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument(); + }); + + it("disables Approve and Reject for a Dispatcher the backend refuses, without dialogs or toasts", async () => { + canApproveState.data = false; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const approve = screen.getByRole("button", { name: "Approve" }); + const reject = screen.getByRole("button", { name: "Reject" }); + expect(approve).toBeDisabled(); + expect(reject).toBeDisabled(); + fireEvent.mouseOver(approve); + expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument(); + fireEvent.click(approve); + fireEvent.click(reject); + expect(approveMutate).not.toHaveBeenCalled(); + expect(rejectMutate).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: /reject uplift of/i })).not.toBeInTheDocument(); + }); + + it("keeps Approve and Reject enabled when the backend allows the tier", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled(); }); }); diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index 355f99f1..644fc329 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal"; import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; import { @@ -49,6 +49,10 @@ function renderCreateModal() { }); } +// delay: null skips user-event's per-keystroke setTimeout yields, which only +// add real-timer latency here. +let user = userEvent.setup({ delay: null }); + async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { target: { value: name }, @@ -57,7 +61,7 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { // phone-or-email rule, so the address is filled to keep the form's only // outstanding problem the one under test. fillRequiredAddress(); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); } function fillRequiredAddress({ @@ -74,7 +78,15 @@ function fillRequiredAddress({ pickVendorState(state); } -describe("VendorCreateModal validation", () => { +// Each test mounts the full MUI dialog (Autocompletes, 50-option State list) +// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under +// parallel agent load the 5s default is exceeded by CPU starvation alone, +// matching the 10s budgets already used by vendors-list.test.tsx. +describe("VendorCreateModal validation", { timeout: 15_000 }, () => { + beforeEach(() => { + user = userEvent.setup({ delay: null }); + }); + it("explains the company phone-or-email requirement after submission", async () => { renderCreateModal(); await fillCompanyNameAndSubmit(); @@ -138,8 +150,8 @@ describe("VendorCreateModal validation", () => { const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); const email = screen.getByRole("textbox", { name: "Email (optional)" }); - await userEvent.type(phone, "314"); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.type(phone, "314"); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument(); expect(phone).toHaveAttribute("aria-invalid", "true"); @@ -153,8 +165,8 @@ describe("VendorCreateModal validation", () => { const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); const email = screen.getByRole("textbox", { name: "Email (optional)" }); - await userEvent.type(email, "not-an-email"); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.type(email, "not-an-email"); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); expect(await screen.findByText("Invalid email")).toBeInTheDocument(); expect(email).toHaveAttribute("aria-invalid", "true"); @@ -169,7 +181,7 @@ describe("VendorCreateModal validation", () => { expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument(); - await userEvent.type(phone, "3145550100"); + await user.type(phone, "3145550100"); await waitFor(() => expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(), ); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index 0da2e959..0e5ea3eb 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -68,11 +68,19 @@ function rosterWith(technicians: Array>) { }; } +// delay: null skips user-event's per-keystroke setTimeout yields, which only +// add real-timer latency here. +let user = userEvent.setup({ delay: null }); + beforeEach(() => { saveMutate.mockReset(); + user = userEvent.setup({ delay: null }); }); -describe("VendorDetailDrawer selected-technician display", () => { +// The edit-mode drawer is a heavy MUI render and the first test pays JIT +// warm-up; under parallel agent load the 5s default is exceeded by CPU +// starvation alone, matching the 10s budgets in vendors-list.test.tsx. +describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => { it.each([ { removedPosition: 1, remainingId: 2, remainingName: "Beth" }, { removedPosition: 2, remainingId: 1, remainingName: "Adam" }, @@ -107,10 +115,10 @@ describe("VendorDetailDrawer selected-technician display", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click( + await user.click( screen.getByRole("button", { name: `Remove technician ${removedPosition}` }), ); - await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1)); expect(saveMutate).toHaveBeenCalledWith( @@ -161,12 +169,12 @@ describe("VendorDetailDrawer selected-technician display", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" })); + await user.click(screen.getByRole("button", { name: "Remove technician 2" })); const save = screen.getByRole("button", { name: "Save changes" }); expect(save).toBeEnabled(); - await userEvent.click(save); + await user.click(save); expect( await screen.findByText("Provide a company phone or email (at least one required)"), @@ -444,10 +452,10 @@ describe("VendorDetailDrawer deactivation prompt", () => { ); const toggle = screen.getByRole("switch", { name: "Active status" }); - await userEvent.click(toggle); + await user.click(toggle); expect(toggle).toBeChecked(); - await userEvent.click(toggle); + await user.click(toggle); expect(toggle).not.toBeChecked(); expect(onRequestDeactivation).not.toHaveBeenCalled(); @@ -472,7 +480,7 @@ describe("VendorDetailDrawer deactivation prompt", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await user.click(screen.getByRole("switch", { name: "Active status" })); expect(onRequestDeactivation).toHaveBeenCalledWith(vendor); }); diff --git a/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx b/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx new file mode 100644 index 00000000..8801c765 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-wizard-vendor-company-notes.test.tsx @@ -0,0 +1,222 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes"; +import { NOTES_SAVE_FAILED_MESSAGE } from "@/domain/vendors/use-cases/use-save-vendor-company-notes"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { toast } from "react-toastify"; +import { createTestQueryClient } from "@/test/test-utils"; + +const rosterGet = vi.fn(); +const addTechnicians = vi.fn(); + +vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({ + vendorCompanyRosterApi: { + get: (...args: unknown[]) => rosterGet(...args), + addTechnicians: (...args: unknown[]) => addTechnicians(...args), + }, +})); + +vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +type Roster = ReturnType; + +function roster(companyId: number, notes: string) { + return { + companyId, + rowVersion: `rv-${companyId}`, + name: `Company ${companyId}`, + companyPhone: "", + email: "", + address: "", + city: "", + state: "", + zip: "", + googleMapsUrl: "", + notes, + technicians: [], + }; +} + +function deferredRoster() { + let resolve!: (value: Roster) => void; + const pending = new Promise((res) => { + resolve = res; + }); + return { + promise: () => pending, + resolveAll: (value: Roster) => resolve(value), + }; +} + +function useHarness() { + const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT }); + const notes = useWizardVendorCompanyNotes(true, draft.vendorId, setDraft); + return { draft, setDraft, ...notes }; +} + +function renderHarness() { + const client = createTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useHarness(), { wrapper }); +} + +describe("useWizardVendorCompanyNotes", () => { + beforeEach(() => { + rosterGet.mockReset(); + addTechnicians.mockReset(); + vi.mocked(toast.error).mockClear(); + rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) => + Promise.resolve( + vendorId === "31" ? roster(9, "Other company notes") : roster(7, "Call before arrival"), + ), + ); + addTechnicians.mockResolvedValue(roster(7, "Gate code 4411")); + }); + + it("pre-fills the Notes field with the picked technician's company notes", async () => { + const { result } = renderHarness(); + + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + expect(rosterGet).toHaveBeenCalledWith({ vendorId: "21", companyId: undefined }); + }); + + it("keeps edits when switching technicians within the same company", async () => { + rosterGet.mockResolvedValue(roster(7, "Call before arrival")); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "22" }))); + + await waitFor(() => + expect(rosterGet).toHaveBeenCalledWith({ vendorId: "22", companyId: undefined }), + ); + expect(result.current.draft.vendorNotes).toBe("Gate code 4411"); + }); + + it("re-seeds notes when the technician belongs to a different company", async () => { + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "31" }))); + + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Other company notes")); + }); + + it("keeps notes typed before the roster resolves and saves them for that company", async () => { + const lateRoster = deferredRoster(); + rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) => + vendorId === "21" ? lateRoster.promise() : Promise.resolve(roster(7, "Call before arrival")), + ); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + + await act(async () => lateRoster.resolveAll(roster(7, "Call before arrival"))); + expect(result.current.draft.vendorNotes).toBe("Gate code 4411"); + + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); + + expect(addTechnicians).toHaveBeenCalledWith(7, { + rowVersion: "rv-7", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }); + }); + + it("targets the newly selected company at save time while its roster is still loading", async () => { + const companyB = deferredRoster(); + rosterGet.mockImplementation(({ vendorId }: { vendorId: string }) => + vendorId === "21" ? Promise.resolve(roster(7, "Call before arrival")) : companyB.promise(), + ); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "31" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + + let savePromise!: Promise; + act(() => { + savePromise = result.current + .saveCompanyNotes({ ...result.current.draft, vendorNotes: "Gate code 4411" }) + .then(() => undefined); + }); + await act(async () => companyB.resolveAll(roster(9, "Other company notes"))); + await act(async () => { + await savePromise; + }); + + expect(addTechnicians).toHaveBeenCalledWith(9, { + rowVersion: "rv-9", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }); + expect(addTechnicians).not.toHaveBeenCalledWith(7, expect.anything()); + expect(result.current.draft.vendorNotes).toBe("Gate code 4411"); + }); + + it("warns without writing when the save-time roster read fails", async () => { + rosterGet.mockRejectedValue(new Error("roster down")); + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + act(() => result.current.setDraft((d) => ({ ...d, vendorNotes: "Gate code 4411" }))); + + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); + + expect(toast.error).toHaveBeenCalledWith(NOTES_SAVE_FAILED_MESSAGE); + expect(addTechnicians).not.toHaveBeenCalled(); + }); + + it("writes edited notes to the vendor company when the work order is created", async () => { + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + await act(async () => { + await result.current.saveCompanyNotes({ + ...result.current.draft, + vendorNotes: "Gate code 4411", + }); + }); + + expect(addTechnicians).toHaveBeenCalledWith(7, { + rowVersion: "rv-7", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }); + }); + + it("does not write when the notes were left as the company's existing notes", async () => { + const { result } = renderHarness(); + act(() => result.current.setDraft((d) => ({ ...d, vendorId: "21" }))); + await waitFor(() => expect(result.current.draft.vendorNotes).toBe("Call before arrival")); + + await act(async () => { + await result.current.saveCompanyNotes(result.current.draft); + }); + + expect(addTechnicians).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx index c46f95df..30658510 100644 --- a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx @@ -15,6 +15,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "9", vendorName: "Old Vendor", techPhone: "555-0100", + vendorNotes: "Old company notes", }; render( @@ -43,6 +44,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "", vendorName: "", techPhone: "", + vendorNotes: "", }), ); }); @@ -56,6 +58,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "9", vendorName: "Old Vendor", techPhone: "555-0100", + vendorNotes: "Old company notes", }; render( @@ -84,6 +87,7 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", ( vendorId: "", vendorName: "", techPhone: "", + vendorNotes: "", }); }); }); diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx index 1ef0734c..4ca3e8d8 100644 --- a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; @@ -120,6 +120,12 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) }; } +// Role and text queries over the whole board scan every row cell; scoping them +// to the pagination footer keeps each waitFor poll cheap on a loaded machine. +function pagination() { + return within(screen.getByTestId("wo-day-pagination")); +} + function pagesAfter(callCount: number): number[] { return searchBoard.mock.calls.slice(callCount).map((call) => { const params = call[0] as WorkOrderBoardSearchQueryParams; @@ -213,14 +219,14 @@ describe("advanced search page sync after mutation", () => { ); }); expect(screen.getByText("WO-0")).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(pagination().getByText("Page 1 of 2")).toBeInTheDocument(); expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Next page" })); + fireEvent.click(pagination().getByRole("button", { name: "Next page" })); await waitFor(() => { expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1"); expect(screen.getByText("WO-25")).toBeInTheDocument(); - expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(pagination().getByText("Page 2 of 2")).toBeInTheDocument(); }); const callsBeforeMutation = searchBoard.mock.calls.length; @@ -236,12 +242,12 @@ describe("advanced search page sync after mutation", () => { expect(screen.getByTestId("harness-state")).toHaveTextContent( "adv=true rows=20 total=20 page=0", ); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(pagination().getByText("Page 1 of 1")).toBeInTheDocument(); }); expect(screen.getByText("WO-0")).toBeInTheDocument(); expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); expect(screen.getByText("RESULTS · 20 items")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(pagination().getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(pagination().getByRole("button", { name: "Next page" })).toBeDisabled(); }, 15000); }); diff --git a/src/test/domain/dashboard/dashboard-api.test.ts b/src/test/domain/dashboard/dashboard-api.test.ts new file mode 100644 index 00000000..95f8c258 --- /dev/null +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), +})); + +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; + +describe("dashboardApi", () => { + beforeEach(() => { + apiGet.mockReset(); + }); + + it("maps stats tolerantly and sends the range params", async () => { + apiGet.mockResolvedValue({ + Data: { + Total: 12, + Open: 8, + NotDispatched: 2, + Completed: 4, + DueCount: 5, + CompletedDueCount: 3, + CompletionRate: 60, + AverageResolutionDays: 4.5, + ScheduledTomorrow: "3", + PendingUplifts: 2, + AvetaPending: "1", + Breakdown: [ + { Status: "Scheduled", Count: "7" }, + { Status: "Completed", Count: 4 }, + ], + StatusDistribution: [ + { Status: "Scheduled", Count: "7" }, + { Status: "Unknown", Count: 1 }, + ], + }, + }); + + const stats = await dashboardApi.getStats({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }); + + expect(stats).toEqual({ + total: 12, + open: 8, + notDispatched: 2, + completed: 4, + dueCount: 5, + completedDueCount: 3, + completionRate: 60, + averageResolutionDays: 4.5, + scheduledTomorrow: 3, + pendingUplifts: 2, + avetaPending: 1, + breakdown: [ + { status: "Scheduled", count: 7 }, + { status: "Completed", count: 4 }, + ], + statusDistribution: [ + { status: "Scheduled", count: 7 }, + { status: "Unknown", count: 1 }, + ], + }); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", { + searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18" }, + }); + }); + + it("maps the object breakdown shape to canonical WOType labels", async () => { + apiGet.mockResolvedValue({ + Data: { + Breakdown: { pm: 3, emergency: 1, reactive: "2", overdue: 4, other: 5 }, + }, + }); + + const stats = await dashboardApi.getStats(); + + expect(stats.breakdown).toEqual([ + { status: "PM", count: 3 }, + { status: "Emergency", count: 1 }, + { status: "Reactive", count: 2 }, + { status: "Overdue", count: 4 }, + { status: "Other", count: 5 }, + ]); + }); + + it("omits search params for the all-time range", async () => { + apiGet.mockResolvedValue({ scheduledTomorrow: 0, pendingUplifts: 0, avetaPending: 0 }); + + await dashboardApi.getStats({}); + + expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", undefined); + }); + + it("maps workload rows and returns the paging envelope", async () => { + apiGet.mockResolvedValue({ + Items: [{ DispatcherId: 12, DispatcherName: "Dana Reyes", OpenCount: "4", TotalCount: 6 }], + Page: 1, + PageSize: 10, + TotalDispatchers: 23, + }); + + await expect(dashboardApi.getWorkload()).resolves.toEqual({ + items: [ + { + dispatcherId: "12", + dispatcherName: "Dana Reyes", + openWorkOrders: 4, + totalWorkOrders: 6, + }, + ], + page: 1, + pageSize: 10, + totalDispatchers: 23, + }); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Workload", { searchParams: { page: 1 } }); + }); + + it("sends the requested page and range and maps performance rows", async () => { + apiGet.mockResolvedValue({ + Items: [ + { + DispatcherId: 12, + DispatcherName: "Dana Reyes", + CompletionRate: 96.5, + AverageResolutionDays: 3.25, + AssignedCount: 40, + CompletedCount: "38", + }, + ], + Page: 2, + PageSize: 10, + TotalDispatchers: 14, + }); + + await expect( + dashboardApi.getPerformance({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }, 2), + ).resolves.toEqual({ + items: [ + { + dispatcherId: "12", + dispatcherName: "Dana Reyes", + completionRate: 96.5, + averageResolutionDays: 3.25, + assignedCount: 40, + completedCount: 38, + }, + ], + page: 2, + pageSize: 10, + totalDispatchers: 14, + }); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Performance", { + searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18", page: 2 }, + }); + }); + + it("falls back to the requested page for the legacy bare-array shape", async () => { + apiGet.mockResolvedValue([ + { DispatcherId: 7, DispatcherName: "Lee Ash", OpenCount: 1, TotalCount: 2 }, + ]); + + await expect(dashboardApi.getWorkload({}, 1)).resolves.toEqual({ + items: [ + { dispatcherId: "7", dispatcherName: "Lee Ash", openWorkOrders: 1, totalWorkOrders: 2 }, + ], + page: 1, + pageSize: 10, + totalDispatchers: 1, + }); + }); + + it("maps unknown regions to Unmapped", async () => { + apiGet.mockResolvedValue({ + Items: [{ Region: "North", Count: 5 }, { Name: " ", WorkOrderCount: 2 }, {}], + }); + + await expect(dashboardApi.getRegions()).resolves.toEqual([ + { region: "North", workOrderCount: 5 }, + { region: "Unmapped/Other", workOrderCount: 2 }, + { region: "Unmapped/Other", workOrderCount: 0 }, + ]); + }); + + it("maps vendor insights with company-name fallback", async () => { + apiGet.mockResolvedValue({ + Items: [ + { + VendorCompanyId: 3, + VendorCompanyName: "Gateway Plumbing", + CompletionRate: 82, + RescheduleRate: 4, + AverageResolutionDays: 3, + TotalJobs: 11, + }, + ], + }); + + await expect(dashboardApi.getVendorInsights()).resolves.toEqual([ + { + vendorId: "3", + vendorName: "Gateway Plumbing", + completionRate: 82, + rescheduleRate: 4, + averageResolutionDays: 3, + totalJobs: 11, + }, + ]); + }); + + it("maps the trend contract and sends date, granularity, and year params", async () => { + apiGet.mockResolvedValue({ + Granularity: "Week", + Buckets: [{ Date: "2026-09-07", Label: "2026-W37", Total: "9", IsCurrent: true }], + }); + + await expect( + dashboardApi.getTrend({ + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + granularity: "month", + year: 2026, + }), + ).resolves.toEqual({ + granularity: "week", + points: [ + { + date: "2026-09-07", + label: "2026-W37", + total: 9, + open: 0, + completed: 0, + canceled: 0, + overdue: 0, + isCurrent: true, + }, + ], + }); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Trend", { + searchParams: { + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + granularity: "month", + year: 2026, + }, + }); + }); +}); diff --git a/src/test/domain/dashboard/dashboard-range-utils.test.ts b/src/test/domain/dashboard/dashboard-range-utils.test.ts new file mode 100644 index 00000000..61317746 --- /dev/null +++ b/src/test/domain/dashboard/dashboard-range-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + addDaysIso, + businessTodayIso, + dashboardRangeParams, + mondayOf, + monthEndIso, + monthStartIso, +} from "@/domain/dashboard/utils/dashboard-range-utils"; + +describe("businessTodayIso", () => { + it("formats the America/New_York calendar date regardless of UTC time", () => { + expect(businessTodayIso(new Date("2026-09-16T23:30:00Z"))).toBe("2026-09-16"); + expect(businessTodayIso(new Date("2026-09-17T02:30:00Z"))).toBe("2026-09-16"); + expect(businessTodayIso(new Date("2026-01-01T04:30:00Z"))).toBe("2025-12-31"); + }); +}); + +describe("addDaysIso", () => { + it("rolls over month and year boundaries", () => { + expect(addDaysIso("2026-01-31", 1)).toBe("2026-02-01"); + expect(addDaysIso("2026-12-31", 1)).toBe("2027-01-01"); + expect(addDaysIso("2026-09-14", -7)).toBe("2026-09-07"); + }); +}); + +describe("mondayOf", () => { + it("returns the Monday of the ISO week", () => { + expect(mondayOf("2026-09-16")).toBe("2026-09-14"); + expect(mondayOf("2026-09-14")).toBe("2026-09-14"); + expect(mondayOf("2026-09-13")).toBe("2026-09-07"); + }); +}); + +describe("monthStartIso / monthEndIso", () => { + it("returns month bounds", () => { + expect(monthStartIso("2026-09-16")).toBe("2026-09-01"); + expect(monthEndIso("2026-09-16")).toBe("2026-09-30"); + expect(monthEndIso("2026-02-16")).toBe("2026-02-28"); + }); +}); + +describe("dashboardRangeParams", () => { + it("maps today to the business date", () => { + expect(dashboardRangeParams("today", "2026-09-16")).toEqual({ + dateFrom: "2026-09-16", + dateTo: "2026-09-16", + }); + }); + + it("maps this week to the Monday-Friday business week", () => { + expect(dashboardRangeParams("this-week", "2026-09-16")).toEqual({ + dateFrom: "2026-09-14", + dateTo: "2026-09-18", + }); + }); + + it("maps last 3 months to the trailing month span", () => { + expect(dashboardRangeParams("last-3-months", "2026-09-16")).toEqual({ + dateFrom: "2026-07-01", + dateTo: "2026-09-30", + }); + }); + + it("maps all time to unbounded params", () => { + expect(dashboardRangeParams("all-time", "2026-09-16")).toEqual({}); + }); +}); diff --git a/src/test/domain/dashboard/work-order-drilldown-links.test.ts b/src/test/domain/dashboard/work-order-drilldown-links.test.ts new file mode 100644 index 00000000..836da9e6 --- /dev/null +++ b/src/test/domain/dashboard/work-order-drilldown-links.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, + workOrderDrilldownUrl, + workOrderRangeDrilldownSearch, + workOrderStatusDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; + +describe("work-order drilldown links", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("schedules the tomorrow drilldown on the business clock", () => { + const search = scheduledTomorrowDrilldownSearch("2026-09-16"); + + expect(search.get("dateFrom")).toBe("2026-09-17"); + expect(search.get("dateTo")).toBe("2026-09-17"); + }); + + it("builds the Aveta Pending drilldown per SH-294", () => { + const search = avetaPendingDrilldownSearch("2026-09-16"); + const statuses = (search.get("statuses") ?? "").split(","); + + expect(search.get("dateFrom")).toBe("2026-09-16"); + expect(search.get("dateTo")).toBe("2026-09-17"); + expect(search.get("aveta")).toBe("1"); + // `docs=No` is the completion-document status, not the Aveta document, so it + // must not be part of the Aveta Pending drilldown (SH-294 / backend#138). + expect(search.get("docs")).toBeNull(); + expect(statuses).toContain("Scheduled"); + expect(statuses).not.toContain("Completed"); + }); + + it("carries the range into status drilldowns", () => { + // "today" resolves against the business clock, so pin it (midday EDT on + // 2026-09-16 → 2026-09-16 in America/New_York) to keep the test deterministic. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-16T16:00:00Z")); + + const search = workOrderStatusDrilldownSearch("Scheduled", "today"); + + expect(search.get("dateFrom")).toBe("2026-09-16"); + expect(search.get("dateTo")).toBe("2026-09-16"); + expect(search.get("statuses")).toBe("Scheduled"); + }); + + it("omits dates for the all-time range", () => { + expect(workOrderRangeDrilldownSearch("all-time").toString()).toBe(""); + }); + + it("renders plain or query-bearing work order URLs", () => { + expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders"); + expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe( + "/workorders?statuses=Scheduled", + ); + }); +}); diff --git a/src/test/domain/team-members/api/team-members-api.test.ts b/src/test/domain/team-members/api/team-members-api.test.ts index 158917f9..9fcb73de 100644 --- a/src/test/domain/team-members/api/team-members-api.test.ts +++ b/src/test/domain/team-members/api/team-members-api.test.ts @@ -3,10 +3,12 @@ import { API_PATHS } from "@/api/api-paths"; const apiGet = vi.fn(); const apiPost = vi.fn(); +const apiPut = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), apiPost: (...args: unknown[]) => apiPost(...args), + apiPut: (...args: unknown[]) => apiPut(...args), })); import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; @@ -15,6 +17,7 @@ describe("teamMembersApi", () => { beforeEach(() => { apiGet.mockReset(); apiPost.mockReset(); + apiPut.mockReset(); }); it("posts the pending team member contract without invitation fields", async () => { @@ -45,4 +48,47 @@ describe("teamMembersApi", () => { expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("invite"); expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("password"); }); + + it("gets and updates detail through the team-member detail route", async () => { + apiGet.mockResolvedValueOnce({ + data: { + id: "u/1", + name: "Taylor Dispatcher", + role: "Dispatcher", + status: "Active", + serviceAreas: ["East"], + permissions: [{ permissionKey: "createSites", overrideState: 1, isGranted: true }], + }, + }); + apiPut.mockResolvedValueOnce({ + data: { + id: "u/1", + name: "Taylor Updated", + role: "Dispatcher", + status: "Inactive", + serviceAreas: [], + permissions: [], + }, + }); + + await expect(teamMembersApi.getDetail("u/1")).resolves.toMatchObject({ + id: "u/1", + serviceAreas: ["East"], + }); + const input = { + name: "Taylor Updated", + role: "Dispatcher" as const, + color: "#F59E0B", + email: "taylor@example.com", + serviceAreas: [], + permissionOverrides: { createSites: 2 as const }, + isActive: false, + }; + await expect(teamMembersApi.update("u/1", input)).resolves.toMatchObject({ + name: "Taylor Updated", + status: "Inactive", + }); + expect(apiGet).toHaveBeenCalledWith("team-members/u%2F1"); + expect(apiPut).toHaveBeenCalledWith("team-members/u%2F1", input); + }); }); diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts index 8ef293a1..164a2ef7 100644 --- a/src/test/domain/team-members/team-member-mapper.test.ts +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { mapTeamMemberListItem } from "@/domain/team-members/mappers/team-member-mapper"; +import { + mapTeamMemberDetail, + mapTeamMemberListItem, +} from "@/domain/team-members/mappers/team-member-mapper"; describe("mapTeamMemberListItem", () => { it("maps the existing User list response and derives inactive state", () => { @@ -27,3 +30,28 @@ describe("mapTeamMemberListItem", () => { }); }); }); + +describe("mapTeamMemberDetail", () => { + it("maps service areas and the full permission summary", () => { + expect( + mapTeamMemberDetail({ + Id: "u1", + Name: "Taylor", + Role: "Scheduler", + Status: "Active", + ServiceAreas: ["Central", "West"], + Permissions: [ + { PermissionKey: "createSites", OverrideState: 2, IsGranted: false }, + { PermissionKey: "editSites", OverrideState: "Allow", IsGranted: true }, + ], + }), + ).toMatchObject({ + id: "u1", + serviceAreas: ["Central", "West"], + permissions: [ + { permissionKey: "createSites", overrideState: 2, isGranted: false }, + { permissionKey: "editSites", overrideState: 1, isGranted: true }, + ], + }); + }); +}); diff --git a/src/test/domain/uplifts/api/uplifts-api.test.ts b/src/test/domain/uplifts/api/uplifts-api.test.ts new file mode 100644 index 00000000..f43c33ad --- /dev/null +++ b/src/test/domain/uplifts/api/uplifts-api.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiRequestRaw = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), + apiRequestRaw: (...args: unknown[]) => apiRequestRaw(...args), +})); + +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; + +describe("upliftsApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + apiRequestRaw.mockReset(); + }); + + it("posts admin revoke decisions to the uplift decision endpoint", async () => { + apiPost.mockResolvedValueOnce(undefined); + + await upliftsApi.revoke(42, "Scope was already covered"); + + expect(apiPost).toHaveBeenCalledWith("uplifts/42/revoke", { + note: "Scope was already covered", + }); + }); + + it("reads the backend tier decision permission", async () => { + apiGet.mockResolvedValueOnce({ status: "Success", data: { canApprove: true } }); + + await expect(upliftsApi.canApprove(1)).resolves.toBe(true); + + expect(apiGet).toHaveBeenCalledWith("uplifts/can-approve", { searchParams: { tier: 1 } }); + }); + + it("treats a missing tier permission as false", async () => { + apiGet.mockResolvedValueOnce({ status: "Success", data: {} }); + + await expect(upliftsApi.canApprove(2)).resolves.toBe(false); + }); +}); diff --git a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts index 75c5cfde..702b7cae 100644 --- a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts +++ b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { mapUpliftQueueItem } from "@/domain/uplifts/mappers/uplift-mapper"; +import { + mapUpliftQueueExposureTotal, + mapUpliftQueueItem, +} from "@/domain/uplifts/mappers/uplift-mapper"; describe("mapUpliftQueueItem", () => { it("maps ASP.NET PascalCase payload with queue fields", () => { @@ -56,4 +59,71 @@ describe("mapUpliftQueueItem", () => { expect(result.status).toBe("Approved"); expect(result.dispatchNumber).toBe("D-1"); }); + + it("maps queue display fields in PascalCase", () => { + const result = mapUpliftQueueItem({ + id: 1, + WoNumber: "WO-9", + WorkOrderSite: "Site A", + WorkOrderService: "Plumbing", + RequestedByName: "Pat Approver", + AttachmentCount: 3, + WorkOrderAutoApprovedTotal: 100, + WorkOrderAdminApprovedTotal: 250, + WorkOrderApprovedExposureTotal: 350, + WorkOrderClosed: true, + }); + + expect(result.woNumber).toBe("WO-9"); + expect(result.site).toBe("Site A"); + expect(result.serviceName).toBe("Plumbing"); + expect(result.requestedByVendorName).toBe("Pat Approver"); + expect(result.attachmentCount).toBe(3); + expect(result.approvedOnWoAuto).toBe(100); + expect(result.approvedOnWoAdmin).toBe(250); + expect(result.approvedOnWoTotal).toBe(350); + expect(result.workOrderClosed).toBe(true); + }); + + it("maps queue display fields in camelCase", () => { + const result = mapUpliftQueueItem({ + id: 2, + woNumber: "WO-10", + siteCode: "S-2", + trade: "HVAC", + attachmentCount: 2, + autoApprovedTotal: 50, + adminApprovedTotal: 75, + approvedOnWorkOrderTotal: 125, + isWorkOrderClosed: false, + }); + + expect(result.woNumber).toBe("WO-10"); + expect(result.site).toBe("S-2"); + expect(result.serviceName).toBe("HVAC"); + expect(result.attachmentCount).toBe(2); + expect(result.approvedOnWoAuto).toBe(50); + expect(result.approvedOnWoAdmin).toBe(75); + expect(result.approvedOnWoTotal).toBe(125); + expect(result.workOrderClosed).toBe(false); + }); + + it("tolerates absent optional queue fields", () => { + const result = mapUpliftQueueItem({ id: 3 }); + + expect(result.woNumber).toBe(""); + expect(result.site).toBe(""); + expect(result.serviceName).toBe(""); + expect(result.attachmentCount).toBeNull(); + expect(result.approvedOnWoAuto).toBeNull(); + expect(result.approvedOnWoAdmin).toBeNull(); + expect(result.approvedOnWoTotal).toBeNull(); + expect(result.workOrderClosed).toBeNull(); + }); + + it("maps pending exposure total from the queue payload", () => { + expect(mapUpliftQueueExposureTotal({ PendingExposureTotal: 1250 })).toBe(1250); + expect(mapUpliftQueueExposureTotal({ pendingExposureTotal: "900" })).toBe(900); + expect(mapUpliftQueueExposureTotal({})).toBeNull(); + }); }); diff --git a/src/test/domain/uplifts/use-uplift-actions.test.tsx b/src/test/domain/uplifts/use-uplift-actions.test.tsx new file mode 100644 index 00000000..ac37f8bf --- /dev/null +++ b/src/test/domain/uplifts/use-uplift-actions.test.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const approve = vi.fn(); + +vi.mock("@/domain/uplifts/api/uplifts-api", () => ({ + upliftsApi: { + approve: (...args: unknown[]) => approve(...args), + }, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +import { useApproveUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useApproveUplift", () => { + it("refreshes work-order views after a queue decision", async () => { + approve.mockResolvedValueOnce(undefined); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const invalidateSpy = vi.spyOn(client, "invalidateQueries"); + const { result } = renderHook(() => useApproveUplift(), { + wrapper: makeWrapper(client), + }); + + act(() => { + result.current.mutate({ id: 17 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(approve).toHaveBeenCalledWith(17, undefined); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.uplifts.all }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.workOrders.all }); + }); +}); diff --git a/src/test/domain/vendors/utils/build-company-notes-patch.test.ts b/src/test/domain/vendors/utils/build-company-notes-patch.test.ts new file mode 100644 index 00000000..824ad1c8 --- /dev/null +++ b/src/test/domain/vendors/utils/build-company-notes-patch.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildCompanyNotesUpdate } from "@/domain/vendors/utils/build-company-notes-patch"; + +const baseline = { companyId: 12, rowVersion: "AAAAAAAAB9E=", notes: "Call before arrival" }; + +describe("buildCompanyNotesUpdate", () => { + it("builds a notes-only additive patch when the notes changed", () => { + expect(buildCompanyNotesUpdate(baseline, " Gate code 4411 ")).toEqual({ + companyId: 12, + patch: { + rowVersion: "AAAAAAAAB9E=", + addTechnicians: [], + companyFields: { notes: "Gate code 4411" }, + }, + }); + }); + + it("skips the write when the notes are unchanged apart from whitespace", () => { + expect(buildCompanyNotesUpdate(baseline, "Call before arrival ")).toBeNull(); + }); + + it("skips the write without a loaded company baseline", () => { + expect(buildCompanyNotesUpdate(null, "New notes")).toBeNull(); + expect(buildCompanyNotesUpdate({ ...baseline, companyId: null }, "New notes")).toBeNull(); + expect(buildCompanyNotesUpdate({ ...baseline, rowVersion: "" }, "New notes")).toBeNull(); + }); + + it("does not send an emptied field, which the additive patch treats as unchanged", () => { + expect(buildCompanyNotesUpdate(baseline, " ")).toBeNull(); + }); +}); diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts new file mode 100644 index 00000000..8ff30f72 --- /dev/null +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links"; + +describe("parseDashboardDrilldownParams", () => { + it("returns null when no recognized param is present", () => { + expect(parseDashboardDrilldownParams(new URLSearchParams())).toBeNull(); + expect( + parseDashboardDrilldownParams(new URLSearchParams({ dateFrom: "2026-09-16" })), + ).toBeNull(); + expect(parseDashboardDrilldownParams(new URLSearchParams({ aveta: "0" }))).toBeNull(); + }); + + it("maps dates to a custom advanced-filter range", () => { + const filters = parseDashboardDrilldownParams( + new URLSearchParams({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }), + ); + + expect(filters).not.toBeNull(); + expect(filters?.dateRange).toBe("custom"); + expect(filters?.customFrom).toBe("2026-09-14"); + expect(filters?.customTo).toBe("2026-09-18"); + }); + + it("parses the Aveta Pending drilldown and drops unknown statuses", () => { + const params = avetaPendingDrilldownSearch("2026-09-16"); + params.append("statuses", "NotAStatus"); + const filters = parseDashboardDrilldownParams(params); + + expect(filters).not.toBeNull(); + expect(filters?.avetaOnly).toBe(true); + // The Aveta drilldown no longer sends `docs=No` (completion-doc status), so + // no completion-document filter is applied. + expect(filters?.docs.size).toBe(0); + expect(filters?.statuses).not.toBeNull(); + expect([...(filters?.statuses ?? [])]).not.toContain("Completed"); + expect([...(filters?.statuses ?? [])]).not.toContain("NotAStatus"); + expect(filters?.statuses.has("Scheduled")).toBe(true); + expect(filters?.statuses.has("Pending Quote")).toBe(true); + }); + + it("parses dispatcher and uplift params", () => { + const filters = parseDashboardDrilldownParams( + new URLSearchParams({ + dispatchers: " 12 , 13", + upliftStatuses: "pending,bogus", + }), + ); + + expect(filters?.dispatchers).toEqual(new Set(["12", "13"])); + expect(filters?.hasUplift).toBe(true); + expect(filters?.upliftStatuses).toEqual(new Set(["pending"])); + }); + + it("parses the region drilldown and drops unknown region names", () => { + const filters = parseDashboardDrilldownParams( + new URLSearchParams({ regions: "California, Atlantis" }), + ); + + expect(filters?.regions).toEqual(new Set(["California"])); + }); + + it("rejects partial or malformed date ranges", () => { + expect( + parseDashboardDrilldownParams( + new URLSearchParams({ dateFrom: "2026-9-16", dateTo: "2026-09-18" }), + ), + ).toBeNull(); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index 7f34bcc8..fb24f6b5 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -6,6 +6,10 @@ import { mapBoardDetailMediaItem, mapWorkOrderBoardDetail, } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; const SAMPLE_DETAIL = { info: { @@ -279,3 +283,56 @@ describe("mapWorkOrderBoardDetail", () => { expect(completionForm.get("file")).toBeInstanceOf(File); }); }); + +describe("mapWorkOrderBoardDetail media count (SH-339)", () => { + const reactiveInfo = { ...SAMPLE_DETAIL.info, workOrderType: 6 }; + + it("derives mediaCount from stored media when detail info omits it", () => { + const detail = mapWorkOrderBoardDetail({ ...SAMPLE_DETAIL, info: reactiveInfo }); + + expect(detail.info.type).toBe("Reactive"); + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("prefers stored media when detail info reports an explicit zero", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 0 }, + }); + + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("does not count legacy URL-column media, matching the board row count", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: reactiveInfo, + media: [ + { + id: -1, + category: 1, + url: "https://cdn.example.com/legacy.jpg", + fileName: "legacy.jpg", + isLegacy: true, + }, + ], + }); + + expect(detail.info.mediaCount).toBe(0); + }); + + it("keeps an explicit info mediaCount", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 5 }, + }); + + expect(detail.info.mediaCount).toBe(5); + }); +}); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index a2e1f2eb..022d36fe 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; @@ -526,6 +526,41 @@ describe("computeIsPastDue", () => { }); }); +describe("computeIsPastDue across the local/UTC day boundary", () => { + beforeEach(() => { + // UTC-3 with no DST: local evening is already the next day in UTC. + vi.stubEnv("TZ", "America/Sao_Paulo"); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("does not flag today's work as Past Due at 23:30 local (02:30 UTC next day)", () => { + vi.setSystemTime(new Date("2026-09-17T02:30:00.000Z")); + + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe( + false, + ); + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-15", status: "Scheduled" }))).toBe( + true, + ); + }); + + it("flags yesterday's work as Past Due at 00:30 local", () => { + vi.setSystemTime(new Date("2026-09-17T03:30:00.000Z")); + + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe( + true, + ); + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-17", status: "Scheduled" }))).toBe( + false, + ); + }); +}); + describe("enrichTableRow", () => { it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => { const enriched = enrichTableRow( diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts index eddb88c5..ac185d49 100644 --- a/src/test/lib/auth/user-utils.test.ts +++ b/src/test/lib/auth/user-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isAdminUser } from "@/lib/auth/user-utils"; +import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils"; describe("isAdminUser", () => { it("returns true when Admin appears in roles", () => { @@ -13,3 +13,17 @@ describe("isAdminUser", () => { expect(isAdminUser(undefined)).toBe(false); }); }); + +describe("canViewAllDispatchersOnDashboard", () => { + it("grants the dashboard picker to Admin and Scheduler", () => { + expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true); + expect(canViewAllDispatchersOnDashboard("Scheduler")).toBe(true); + expect(canViewAllDispatchersOnDashboard("Dispatcher, Scheduler")).toBe(true); + }); + + it("locks a Dispatcher (and unknown roles) to their own work orders", () => { + expect(canViewAllDispatchersOnDashboard("Dispatcher")).toBe(false); + expect(canViewAllDispatchersOnDashboard("Technician")).toBe(false); + expect(canViewAllDispatchersOnDashboard(undefined)).toBe(false); + }); +}); diff --git a/src/test/lib/query/query-client.test.ts b/src/test/lib/query/query-client.test.ts new file mode 100644 index 00000000..430d1415 --- /dev/null +++ b/src/test/lib/query/query-client.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAppQueryClient } from "@/lib/query/query-client"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error }, +})); + +describe("createAppQueryClient query cache", () => { + beforeEach(() => { + toastMocks.error.mockReset(); + }); + + it("does not toast for queries that suppress the error toast", async () => { + const queryClient = createAppQueryClient(); + + await expect( + queryClient.fetchQuery({ + queryKey: ["suppressed-403"], + queryFn: () => + Promise.reject(new Error("You do not have permission to perform this action.")), + meta: { suppressErrorToast: true }, + }), + ).rejects.toThrow("You do not have permission"); + + expect(toastMocks.error).not.toHaveBeenCalled(); + }); + + it("still toasts for queries without suppression", async () => { + const queryClient = createAppQueryClient(); + + await expect( + queryClient.fetchQuery({ + queryKey: ["unsuppressed-failure"], + queryFn: () => Promise.reject(new Error("A server error occurred.")), + }), + ).rejects.toThrow("A server error occurred."); + + expect(toastMocks.error).toHaveBeenCalledWith("A server error occurred."); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index f2b26ff5..d435a466 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -39,24 +39,27 @@ if (!localStorageIsUsable()) { }); } -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }), -}); +// Files that opt into `@vitest-environment node` (real fetch/FormData semantics) have no window. +if (typeof window !== "undefined") { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); -class ResizeObserverMock { - observe() {} - unobserve() {} - disconnect() {} + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + window.ResizeObserver = ResizeObserverMock; } - -window.ResizeObserver = ResizeObserverMock;