diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ab2cfb56..cde0f414 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,3 +41,25 @@ jobs: cache: npm - run: npm ci - run: npm run verify + + vendor-visual-regression: + runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.61.1-noble + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run test:e2e:visual + - name: Upload visual diff artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: vendor-visual-regression-diffs + path: | + test-results/visual + playwright-report-visual + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 5b09a167..aabcecf0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /coverage /test-results /playwright-report +/playwright-report-visual /blob-report /playwright/.cache diff --git a/.prettierignore b/.prettierignore index 27f10fea..fddfe3d3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ dist build node_modules coverage +playwright-report-visual package-lock.json *.tsbuildinfo @@ -10,4 +11,4 @@ src/pages/** src/**/*.js src/**/*.jsx src/**/*.css -!src/styles/globals.css \ No newline at end of file +!src/styles/globals.css diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png new file mode 100644 index 00000000..6e091c72 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png new file mode 100644 index 00000000..1beb0a15 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png new file mode 100644 index 00000000..a80af4db Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png new file mode 100644 index 00000000..6c1ca3b4 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png new file mode 100644 index 00000000..f5948c0d Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png new file mode 100644 index 00000000..a1509c39 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png new file mode 100644 index 00000000..c3e9be5b Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png new file mode 100644 index 00000000..15eea83d Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png new file mode 100644 index 00000000..901b2ab5 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.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 new file mode 100644 index 00000000..a2eeb471 Binary files /dev/null and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile.png differ diff --git a/e2e/vendor-portal/portal-lifecycle.spec.ts b/e2e/vendor-portal/portal-lifecycle.spec.ts new file mode 100644 index 00000000..294051a6 --- /dev/null +++ b/e2e/vendor-portal/portal-lifecycle.spec.ts @@ -0,0 +1,221 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const PORTAL_TOKEN = "portal-e2e-token"; + +const futureDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); +const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + +interface DispatchDetail { + id: number; + dispatchNumber: string; + workOrderTitle?: string; + internalWONumber?: string; + status: string; + nteAmount?: number; + scheduledDate?: string; + poNumber?: string; + dispatchedAt?: string; + completedDate?: string; + refusedAt?: string; + statusUpdatedAt?: string; + description?: string; + workOrder?: Record; + location?: Record; + dispatcherContact?: { name?: string; email?: string; phone?: string }; + checklist: never[]; + comments: never[]; + upliftRequests: never[]; + signoffs: never[]; + documents: never[]; +} + +const sentDetail: DispatchDetail = { + id: 101, + dispatchNumber: "DSP-101", + workOrderTitle: "Emergency boiler repair", + internalWONumber: "WO-8842", + status: "Sent", + nteAmount: 1250, + scheduledDate: futureDate, + poNumber: "PO-555", + statusUpdatedAt: "2026-08-09T16:30:00Z", + description: "Boiler is leaking; vendor visit required.", + workOrder: { + workerOrderTitle: "Emergency boiler repair", + internalWONumber: "WO-8842", + trade: "Plumbing", + subTrade: "Backflow Preventers", + siteCode: "STL-01", + service: "Boiler Repair", + }, + location: { + name: "Central Distribution Center", + address: "100 Industrial Pkwy", + city: "St. Louis", + state: "MO", + zipCode: "63101", + }, + dispatcherContact: { + name: "Dana Field", + email: "dana@seahavenind.com", + phone: "314-555-0142", + }, + checklist: [], + comments: [], + upliftRequests: [], + signoffs: [], + documents: [], +}; + +const listDispatches = [ + { + id: 101, + dispatchNumber: "DSP-101", + workOrderTitle: "Emergency boiler repair", + internalWONumber: "WO-8842", + locationName: "Central Distribution Center", + locationCity: "St. Louis", + locationState: "MO", + status: "Sent", + nteAmount: 1250, + scheduledDate: futureDate, + }, + { + id: 102, + dispatchNumber: "DSP-102", + workOrderTitle: "HVAC quarterly service", + status: "Refused", + refusedAt: "2026-08-08T10:00:00Z", + }, + { + id: 103, + dispatchNumber: "DSP-103", + workOrderTitle: "PastAcknowledged job", + status: "Acknowledged", + scheduledDate: pastDate, + }, +]; + +async function fulfill(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {}) { + const detailState: { current: DispatchDetail } = { current: { ...sentDetail } }; + + await page.route("**/api/vendor-portal/session", (route) => { + if (options.sessionError) { + fulfill(route, { message: "Invalid or expired token" }, 401); + return; + } + fulfill(route, { data: { companyName: "Gateway Plumbing", vendorId: 101 } }); + }); + + await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/refuse$/, async (route) => { + const body = route.request().postDataJSON() as { reason?: string }; + detailState.current = { + ...detailState.current, + status: "Refused", + refusedAt: new Date().toISOString(), + }; + fulfill(route, { + data: { + id: detailState.current.id, + status: "Refused", + refusedAt: detailState.current.refusedAt, + reason: body?.reason, + }, + }); + }); + + await page.route(/\/api\/vendor-portal\/dispatches\/\d+$/, (route) => + fulfill(route, { data: detailState.current }), + ); + + await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) => + fulfill(route, { data: listDispatches }), + ); + + return detailState; +} + +test.describe("Vendor portal lifecycle", () => { + test("upcoming filter, detail snapshot, refuse dialog, and locked Refused state", async ({ + page, + }) => { + await mockPortalApi(page); + await page.goto(`/v/${PORTAL_TOKEN}/dashboard`); + + await expect(page.getByRole("heading", { name: "Your Work" })).toBeVisible(); + + await expect(page.getByRole("button", { name: /^Upcoming/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Refused/ })).toBeVisible(); + + await page.getByRole("button", { name: /^Upcoming/ }).click(); + await expect(page.getByText("DSP-101 — Emergency boiler repair")).toBeVisible(); + await expect(page.getByText("DSP-103")).toHaveCount(0); + + await page.getByText("DSP-101 — Emergency boiler repair").click(); + await expect(page).toHaveURL(/\/dispatch\/101$/); + + await expect(page.getByRole("heading", { name: "DSP-101" })).toBeVisible(); + await expect(page.getByText("Boiler Repair", { exact: true })).toBeVisible(); + await expect(page.getByText("STL-01")).toBeVisible(); + await expect(page.getByText("$1250.00").first()).toBeVisible(); + await expect(page.getByRole("link", { name: "dana@seahavenind.com" })).toBeVisible(); + await expect(page.getByText("Dana Field")).toBeVisible(); + await expect(page.getByText("314-555-0142")).toBeVisible(); + await expect(page.getByText(/Status updated/i)).toBeVisible(); + + await page.getByRole("button", { name: "Refuse Dispatch" }).click(); + const dialog = page.getByRole("dialog", { name: "Refuse dispatch" }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel(/Reason/i).fill("Equipment unavailable this week."); + await expect(dialog.getByText(/\d+\/500/)).toBeVisible(); + await dialog.getByRole("button", { name: "Refuse dispatch" }).click(); + + await expect(dialog).toHaveCount(0); + await expect(page.getByText("Refused", { exact: true })).toBeVisible(); + await expect(page.getByText(/refused and locked/i)).toBeVisible(); + await expect(page.getByRole("button", { name: "Accept Dispatch" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Refuse Dispatch" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Submit Signoff" })).toHaveCount(0); + }); + + test("covers the refuse dialog at a mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockPortalApi(page); + await page.goto(`/v/${PORTAL_TOKEN}/dashboard`); + + await page.getByText("DSP-101 — Emergency boiler repair").click(); + await expect(page).toHaveURL(/\/dispatch\/101$/); + + await page.getByRole("button", { name: "Refuse Dispatch" }).click(); + const dialog = page.getByRole("dialog", { name: "Refuse dispatch" }); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + + await page.getByRole("button", { name: "Refuse Dispatch" }).click(); + await page + .getByRole("dialog", { name: "Refuse dispatch" }) + .getByRole("button", { + name: "Refuse dispatch", + }) + .click(); + await expect(page.getByText("Refused", { exact: true })).toBeVisible(); + await expect(page.getByText(/refused and locked/i)).toBeVisible(); + }); + + test("shows an expired-token state when the session is rejected", async ({ page }) => { + await mockPortalApi(page, { sessionError: true }); + await page.goto(`/v/${PORTAL_TOKEN}/dashboard`); + + await expect(page.getByRole("heading", { name: "Access Denied" })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText(/invalid or expired/i); + }); +}); diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index e5c8bcf3..ad1a89e8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -556,6 +556,20 @@ test.describe("Vendor directory prototype parity", () => { await mockVendorApi(page, { listError: true }); await page.goto("/vendors"); + const mainBox = await page.getByRole("main").boundingBox(); + expect(mainBox).toMatchObject({ x: 0, width: 390 }); + const navigation = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(navigation).not.toBeVisible(); + + const openNavigation = page.getByRole("button", { name: "Toggle navigation" }); + await openNavigation.focus(); + await page.keyboard.press("Enter"); + await expect(navigation).toBeVisible(); + await expect(page.getByRole("link", { name: "Sites" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(navigation).not.toBeVisible(); + await expect(openNavigation).toBeFocused(); + await expect(page.getByRole("main").getByRole("alert")).toContainText( /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, @@ -569,4 +583,23 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); await page.getByRole("button", { name: "Close filters" }).click(); }); + + test("keeps protected shell destinations available and marks their selected state", async ({ + page, + }) => { + await mockVendorApi(page); + + for (const destination of [ + { path: "/workorders", label: "Work Orders" }, + { path: "/accounts", label: "Accounts" }, + { path: "/locations", label: "Sites" }, + { path: "/notifications", label: "Notifications" }, + ]) { + await page.goto(destination.path); + await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute( + "aria-current", + "page", + ); + } + }); }); diff --git a/e2e/vendors/vendors.visual.spec.ts b/e2e/vendors/vendors.visual.spec.ts new file mode 100644 index 00000000..705c4f4e --- /dev/null +++ b/e2e/vendors/vendors.visual.spec.ts @@ -0,0 +1,261 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const TEST_USER = { + token: "vendor-visual-token", + expiration: "2030-01-01T00:00:00.000Z", + email: "admin@seahavenind.com", + userRoles: "Admin", + phoneNumber: "5551234567", + fullname: "Vendor Visual Admin", + id: "1", +}; + +const activeVendors = [ + { + Id: 1, + CompanyId: 101, + CompanyName: "Gateway Plumbing", + ContactName: "Adam Whyte", + Email: "dispatch@gateway.test", + Phone: "314-555-0198", + CompanyPhone: "314-555-0100", + Address: "1 Market St", + City: "St. Louis", + State: "MO", + Zip: "63101", + TradeSpecialties: "Plumbing, Backflow Preventers", + GoogleMapsUrl: "https://maps.google.com/gateway", + Notes: "Preferred after-hours vendor", + TotalJobs: 72, + IsActive: true, + PreferredContact: "Email", + }, + { + Id: 2, + CompanyId: 102, + CompanyName: "Metro HVAC", + ContactName: "Maya Chen", + Email: "maya@metro.test", + Phone: "314-555-0112", + CompanyPhone: "314-555-0110", + Address: "2 Pine St", + City: "Clayton", + State: "MO", + Zip: "63105", + TradeSpecialties: "HVAC", + GoogleMapsUrl: "", + Notes: "", + TotalJobs: 18, + IsActive: true, + }, +]; + +const inactiveVendors = [ + { + ...activeVendors[0], + Id: 3, + CompanyId: 103, + CompanyName: "Drain Cleaning Bros", + ContactName: "Drew Banks", + TotalJobs: 151, + IsActive: false, + }, +]; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +async function mockVendorApi(page: Page, mode: "default" | "empty" | "error" = "default") { + await page.clock.setFixedTime(new Date("2026-08-10T12:00:00.000Z")); + await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + + if (!url.pathname.startsWith("/api/")) { + await route.continue(); + return; + } + + if (url.pathname.includes("/DropdownOptions/ByCategory/Trade")) { + await fulfillJson(route, { + data: [ + { id: 1, value: "Plumbing" }, + { id: 2, value: "HVAC" }, + ], + }); + return; + } + + if (url.pathname.endsWith("/api/Vendor/facets")) { + await fulfillJson(route, { + data: { + companies: activeVendors.map((vendor) => ({ + companyId: vendor.CompanyId, + name: vendor.CompanyName, + })), + trades: ["Plumbing", "Backflow Preventers", "HVAC"], + locations: [ + { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, + { city: "Clayton", state: "MO", label: "Clayton, MO" }, + ], + jobBuckets: [ + { id: "under-50", label: "Under 50" }, + { id: "50-99", label: "50–99" }, + { id: "100-149", label: "100–149" }, + { id: "150-plus", label: "150+" }, + ], + }, + }); + return; + } + + if (url.pathname.endsWith("/api/Vendor/GetVendorList")) { + const isCount = url.searchParams.get("pageSize") === "1"; + if (mode === "error" && !isCount) { + await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); + return; + } + const source = + mode === "empty" + ? [] + : url.searchParams.get("isActive") === "false" + ? inactiveVendors + : activeVendors; + await fulfillJson(route, { + Data: isCount ? source.slice(0, 1) : source, + TotalCount: source.length, + PageNumber: 1, + PageSize: Number(url.searchParams.get("pageSize") ?? 12), + }); + return; + } + + if (url.pathname.startsWith("/api/vendor-company-roster")) { + await fulfillJson(route, { + companyId: 101, + rowVersion: "rv-visual", + name: "Gateway Plumbing", + companyPhone: "314-555-0100", + email: "dispatch@gateway.test", + address: "1 Market St", + city: "St. Louis", + state: "MO", + zip: "63101", + googleMapsUrl: "https://maps.google.com/gateway", + notes: "Preferred after-hours vendor", + technicians: [ + { + id: 1, + contactName: "Adam Whyte", + phone: "314-555-0198", + email: "dispatch@gateway.test", + preferredContact: "Email", + tradeSpecialties: "Plumbing, Backflow Preventers", + isActive: true, + totalJobs: 72, + }, + ], + }); + return; + } + + await fulfillJson(route, { data: {} }); + }); +} + +async function openVendorPage(page: Page, mode: "default" | "empty" | "error" = "default") { + await mockVendorApi(page, mode); + await page.goto("/vendors"); + 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 page.screenshot({ animations: "disabled", caret: "hide" }); + await expect(page).toHaveScreenshot(name); +} + +test.describe("Vendor deterministic pixel regression", () => { + test("list", async ({ page }) => { + await openVendorPage(page); + await expect(page.getByText("Gateway Plumbing")).toBeVisible(); + await expectStableScreenshot(page, "vendor-list.png"); + }); + + test("filter", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "Open vendor filters" }).click(); + await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-filter.png"); + }); + + test("add", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); + await expectStableScreenshot(page, "vendor-add.png"); + }); + + test("detail", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + await expect(page.getByRole("button", { name: "Close drawer" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-detail.png"); + }); + + test("edit", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-edit.png"); + }); + + test("inactive", async ({ page }) => { + await openVendorPage(page); + await page.getByRole("tab", { name: "Inactive (1)" }).click(); + await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expectStableScreenshot(page, "vendor-inactive.png"); + }); + + test("empty", async ({ page }) => { + await openVendorPage(page, "empty"); + await expect(page.getByText("No active technicians found")).toBeVisible(); + await expectStableScreenshot(page, "vendor-empty.png"); + }); + + test("error", async ({ page }) => { + await openVendorPage(page, "error"); + await expect(page.getByRole("main").getByRole("alert")).toContainText( + /server error|vendor directory unavailable/i, + ); + await expectStableScreenshot(page, "vendor-error.png"); + }); + + test("mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openVendorPage(page); + await expect(page.getByText("Gateway Plumbing")).toBeVisible(); + await expectStableScreenshot(page, "vendor-mobile.png"); + + await page.getByRole("button", { name: "Toggle navigation" }).click(); + await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible(); + await expectStableScreenshot(page, "vendor-mobile-navigation.png"); + }); +}); diff --git a/package.json b/package.json index 3df2f4da..10e69a08 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:visual": "playwright test --config playwright.visual.config.ts", "test:e2e:ui": "playwright test --ui", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix --max-warnings=0", diff --git a/playwright.config.ts b/playwright.config.ts index 7360b05c..c37b71ed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", + testIgnore: "**/*.visual.spec.ts", fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 00000000..f442c886 --- /dev/null +++ b/playwright.visual.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.visual.spec.ts", + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]], + outputDir: "test-results/visual", + snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}", + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + // Keep the changed-pixel budget strict while ignoring sub-perceptual + // antialiasing differences between local and hosted container runtimes. + maxDiffPixels: 5, + threshold: 0.1, + }, + }, + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4174", + colorScheme: "light", + deviceScaleFactor: 1, + locale: "en-US", + launchOptions: { + args: ["--disable-gpu", "--font-render-hinting=none"], + }, + reducedMotion: "reduce", + timezoneId: "UTC", + trace: "retain-on-failure", + viewport: { width: 1440, height: 1000 }, + }, + webServer: { + command: "npm run dev -- --port 4174 --strictPort", + url: "http://localhost:4174", + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index c3281edf..eca5363c 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Outlet } from "react-router"; -import { Box } from "@mui/material"; +import { Box, useMediaQuery, useTheme } from "@mui/material"; import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; @@ -8,19 +8,36 @@ import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; export default function AdminShellLayout() { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); const [navCollapsed, setNavCollapsed] = useState(false); - const sidebarWidth = getSidebarWidth(navCollapsed); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const sidebarWidth = isDesktop ? getSidebarWidth(navCollapsed) : 0; + + const handleToggleNav = () => { + if (isDesktop) { + setNavCollapsed((value) => !value); + return; + } + + setMobileNavOpen((value) => !value); + }; return ( - + setMobileNavOpen(false)} + /> - setNavCollapsed((value) => !value)} /> + Filters @@ -57,7 +59,7 @@ export function VendorsListToolbar({ variant="primary" icon={} onClick={onAddVendor} - className="w-[115.546875px] border-0 px-3 text-[13px]" + className="min-w-[115.546875px] flex-1 border-0 px-3 text-[13px] md:w-[115.546875px] md:flex-none" > Add Vendor diff --git a/src/app/v/[token]/dashboard.tsx b/src/app/v/[token]/dashboard.tsx index eeb222a2..65b87bff 100644 --- a/src/app/v/[token]/dashboard.tsx +++ b/src/app/v/[token]/dashboard.tsx @@ -8,36 +8,18 @@ import { formatVendorPortalDateTime, vendorPortalStatusClass, } from "@/domain/vendor-portal/lib/status-helpers"; -import type { VendorPortalDispatchSummary } from "@/domain/vendor-portal/types/vendor-portal"; +import { + VENDOR_PORTAL_DISPATCH_FILTERS, + applyVendorPortalDispatchFilter, + getVendorPortalDispatchFilter, + type VendorPortalDispatchFilterKey, +} from "@/domain/vendor-portal/lib/dispatch-filters"; import { queryKeys } from "@/infra/query-key/query-key"; -const FILTERS = [ - { - key: "open", - label: "Open", - match: (dispatch: VendorPortalDispatchSummary) => - dispatch.status !== "Completed" && - dispatch.status !== "Verified" && - dispatch.status !== "Cancelled", - }, - { - key: "completed", - label: "Completed", - match: (dispatch: VendorPortalDispatchSummary) => - dispatch.status === "Completed" || dispatch.status === "Verified", - }, - { - key: "cancelled", - label: "Cancelled", - match: (dispatch: VendorPortalDispatchSummary) => dispatch.status === "Cancelled", - }, - { key: "all", label: "All", match: () => true }, -] as const; - export default function VendorPortalDashboardPage() { const { token } = useVendorPortal(); const navigate = useNavigate(); - const [filter, setFilter] = useState<(typeof FILTERS)[number]["key"]>("open"); + const [filter, setFilter] = useState("open"); const { data: dispatches = [], @@ -49,8 +31,11 @@ export default function VendorPortalDashboardPage() { enabled: Boolean(token), }); - const activeFilter = FILTERS.find((item) => item.key === filter) ?? FILTERS[0]; - const filtered = useMemo(() => dispatches.filter(activeFilter.match), [dispatches, activeFilter]); + const activeFilter = getVendorPortalDispatchFilter(filter); + const filtered = useMemo( + () => applyVendorPortalDispatchFilter(dispatches, activeFilter), + [dispatches, activeFilter], + ); return ( <> @@ -60,7 +45,7 @@ export default function VendorPortalDashboardPage() { Active dispatches and work orders assigned to your company.
- {FILTERS.map((item) => ( + {VENDOR_PORTAL_DISPATCH_FILTERS.map((item) => (
@@ -88,6 +89,7 @@ export function DispatchDetailSections({ existing={data.signoffs ?? []} signoffType="vendor" nameLabel="Your name" + submissionLocked={locked} onAdded={() => void refresh()} /> diff --git a/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx b/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx new file mode 100644 index 00000000..4f71d6d8 --- /dev/null +++ b/src/app/v/[token]/dispatch/_components/dispatch-snapshot-details.tsx @@ -0,0 +1,166 @@ +import { Text } from "@/components/ui/text"; +import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers"; +import type { VendorPortalDispatchDetail } from "@/domain/vendor-portal/types/vendor-portal"; + +type DispatchSnapshotDetailsProps = { + data: VendorPortalDispatchDetail; + locked: boolean; +}; + +function resolveCanonicalService(workOrder: VendorPortalDispatchDetail["workOrder"]): string { + if (workOrder?.service) { + return workOrder.service; + } + + return [workOrder?.trade, workOrder?.subTrade].filter(Boolean).join(" · "); +} + +function DispatchCommercialDetails({ data }: Pick) { + const service = resolveCanonicalService(data.workOrder); + + return ( +
+ {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && ( +
+ WO # + {data.workOrder.internalWONumber} +
+ )} + {Boolean(service) && ( +
+ Service + {service} +
+ )} + {Boolean(data.poNumber) && ( +
+ PO # + {data.poNumber} +
+ )} + {data.nteAmount != null && ( +
+ NTE + ${Number(data.nteAmount).toFixed(2)} +
+ )} +
+ Invoice + + {data.invoiceStatus || "Not Submitted"} + {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} + +
+
+ Payment + {data.paymentStatus || "Unavailable"} +
+ {Boolean(data.scheduledDate) && ( +
+ Scheduled + {formatVendorPortalDateTime(data.scheduledDate)} +
+ )} + {Boolean(data.statusUpdatedAt) && ( +
+ Status updated + {formatVendorPortalDateTime(data.statusUpdatedAt)} +
+ )} +
+ ); +} + +function DispatchLocationDetails({ data }: Pick) { + if (data.location == null) { + return null; + } + + const address = [ + data.location.address, + data.location.city, + data.location.state, + data.location.zipCode, + ] + .filter(Boolean) + .join(", "); + + return ( + <> + + Location + + {Boolean(data.workOrder?.siteCode) && ( +
+ Site code + {data.workOrder?.siteCode} +
+ )} +
{data.location.name}
+
{address}
+ + ); +} + +function DispatcherContactDetails({ data }: Pick) { + const contact = data.dispatcherContact; + if (!contact || (!contact.name && !contact.email && !contact.phone)) { + return null; + } + + return ( + <> + + Dispatcher + + {Boolean(contact.name) && ( +
+ Name + {contact.name} +
+ )} + {Boolean(contact.email) && ( +
+ Email + + {contact.email} + +
+ )} + {Boolean(contact.phone) && ( +
+ Phone + + {contact.phone} + +
+ )} + + ); +} + +export function DispatchSnapshotDetails({ data, locked }: DispatchSnapshotDetailsProps) { + const description = data.description || data.workOrder?.description; + + return ( + <> + + + + {locked && ( + + This dispatch is {data.status.toLowerCase()} and locked to further changes. Comments + remain open. + + )} + {Boolean(description) && ( + <> + + Description + +
{description}
+ + )} + + ); +} diff --git a/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx b/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx index 7dc3bcea..07d93c92 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-status-actions.tsx @@ -3,6 +3,7 @@ type DispatchStatusActionsProps = { locked: boolean; busy: boolean; onAccept: () => void; + onRefuse: () => void; onStart: () => void; onComplete: () => void; onRequestCancel: () => void; @@ -13,6 +14,7 @@ export function DispatchStatusActions({ locked, busy, onAccept, + onRefuse, onStart, onComplete, onRequestCancel, @@ -20,9 +22,19 @@ export function DispatchStatusActions({ return (
{status === "Sent" && ( - + <> + + + )} {status === "Acknowledged" && (
-
- {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && ( -
- WO # - {data.workOrder.internalWONumber} -
- )} - {Boolean(data.poNumber) && ( -
- PO # - {data.poNumber} -
- )} - {data.nteAmount != null && ( -
- NTE - ${Number(data.nteAmount).toFixed(2)} -
- )} -
- Invoice - - {data.invoiceStatus || "Not Submitted"} - {data.invoiceNumber ? ` · ${data.invoiceNumber}` : ""} - -
-
- Payment - {data.paymentStatus || "Unavailable"} -
- {Boolean(data.scheduledDate) && ( -
- Scheduled - {formatVendorPortalDateTime(data.scheduledDate)} -
- )} -
- - {data.location != null && ( - <> - - Location - -
{data.location.name}
-
- {[ - data.location.address, - data.location.city, - data.location.state, - data.location.zipCode, - ] - .filter(Boolean) - .join(", ")} -
- - )} - - {Boolean(data.description || data.workOrder?.description) && ( - <> - - Description - -
{data.description || data.workOrder?.description}
- - )} + void; + onRefused: () => void; +}; + +export function RefuseDispatchDialog({ + open, + dispatchId, + onClose, + onRefused, +}: RefuseDispatchDialogProps) { + const { token } = useVendorPortal(); + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(refuseDispatchSchema), + mode: "onChange", + defaultValues: { reason: "" }, + }); + + const reasonValue = useWatch({ control, name: "reason" }) ?? ""; + const isOverLimit = reasonValue.length > REFUSE_REASON_MAX_LENGTH; + + const refuseMutation = useMutation({ + mutationFn: (values: RefuseDispatchValues) => { + const trimmed = values.reason.trim(); + return vendorPortalApi.refuse(token, dispatchId, trimmed || undefined); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.vendorPortal.dispatches(token), + }), + ]); + onRefused(); + onClose(); + }, + }); + + const { reset: resetMutation } = refuseMutation; + + useEffect(() => { + if (!open) { + reset({ reason: "" }); + resetMutation(); + } + }, [open, reset, resetMutation]); + + const submit = handleSubmit((values) => refuseMutation.mutate(values)); + const errorMessage = refuseMutation.error + ? "Unable to refuse this dispatch. Please try again." + : ""; + + return ( + + Refuse dispatch +
void submit(event)}> + + + Refusing ends your assignment for this dispatch. A reason is optional but helps your + dispatcher reassign the work. + + +