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/docs/adr/0001-query-broadcast-client.md b/docs/adr/0001-query-broadcast-client.md index 4ced20f2..ed40273d 100644 --- a/docs/adr/0001-query-broadcast-client.md +++ b/docs/adr/0001-query-broadcast-client.md @@ -25,17 +25,23 @@ Use TanStack's own experimental broadcast client, wired up in a single dedicated setup module, `src/lib/query/setup-query-broadcast.ts`. The module: -- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a - single named channel (`seahaven-admin-query`). -- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE -=== "test"`), so it never runs in unit tests or node-based tooling. -- Is invoked once from `src/lib/query/query-client.ts` against the app's - singleton `QueryClient`, so every tab that loads the SPA subscribes to the - same channel automatically — no per-feature wiring required. +- Exposes `startQueryBroadcast(queryClient, userId)` and + `stopQueryBroadcast(queryClient)`. +- Keys `broadcastQueryClient` to an **account-scoped** channel + (`seahaven-admin-query:${userId}`), so tabs belonging to different accounts + on the same origin do not share cache traffic. +- Disposes the prior subscription (the unsubscribe returned by + `broadcastQueryClient`) and clears the `QueryClient` when the authenticated + user changes or the session ends. +- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest + (`import.meta.env.MODE === "test"`). +- Is started after authentication (`AuthProvider` session restore + login + success) and stopped on logout and HTTP 401 session clear — not at + `QueryClient` module load. This piggybacks on the query cache we already have (no parallel store), uses -the library that owns the `QueryClient` we already depend on, and requires -close to zero application code (~20 lines) to adopt. +the library that owns the `QueryClient` we already depend on, and keeps the +integration isolated to auth/session boundaries. ### Alternatives considered @@ -44,6 +50,9 @@ close to zero application code (~20 lines) to adopt. serialization/versioning of query keys and mutation results, and keeping every future mutation hook wired to broadcast. More code to own and more surface area for subtle cache-desync bugs. +- **Constant channel from module load** — simplest wiring, but shares one + channel across all sessions on the origin; after logout/login or multi-account + use, cached work-order/vendor data can leak into the next session. Rejected. - **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem unresolved; users would need to manually refresh or wait for `refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough @@ -56,26 +65,27 @@ close to zero application code (~20 lines) to adopt. ## Consequences -- **Positive**: sibling tabs reflect mutations (status changes, dispatch - actions, comments, patches) without a manual refresh; the integration is - isolated to one setup file and does not touch domain/query-key code. +- **Positive**: sibling tabs for the same authenticated account reflect + mutations (status changes, dispatch actions, comments, patches) without a + manual refresh; logout/401 tear down the broadcaster and clear sensitive + cache so the next account cannot inherit prior data. - **Risk — package lifecycle**: the dependency is explicitly "experimental" in the TanStack ecosystem; its API may change or be deprecated between minor versions. `@tanstack/query-broadcast-client-experimental`, `@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to the **exact same version** in `package.json` (no `^` range). All three resolve to the identical `@tanstack/query-core` version at that pin, which - keeps the `QueryClient` type used by `setupQueryBroadcast` structurally + keeps the `QueryClient` type used by the broadcast helpers structurally identical to the one constructed in `query-client.ts` — a caret range lets npm resolve the broadcast client and React Query against two different `query-core` versions independently, which breaks `QueryClient` type identity (TS2322) even though both packages build fine in isolation. Bump all three together and re-evaluate on every TanStack Query upgrade. - **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same - origin; it does not sync across different users/sessions or devices. This is - acceptable for the current requirement (single user, multiple tabs). + origin; it does not sync across different users/sessions or devices. Account + scoping further limits sync to tabs of the same `userId`. - **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental` - dependency from `package.json` and delete the call to `setupQueryBroadcast` - in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module - itself). No other code depends on it, so rollback is a single, isolated - change with no data migration. + dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove + the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and + `api.ts`. No other code depends on it, so rollback is isolated with no data + migration. 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/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts new file mode 100644 index 00000000..18938fd4 --- /dev/null +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -0,0 +1,356 @@ +import { expect, test, type Route } from "@playwright/test"; + +const INTERNAL_USER = { + token: "uplift-approver-token", + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + email: "approver@seahavenind.com", + userRoles: "Admin,Tier2Approver", + fullname: "Uplift Approver", + id: "1", +}; + +async function fulfillJson(route: Route, data: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ status: status < 400 ? "Success" : "Error", data }), + }); +} + +test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", async ({ page }) => { + let requestBody: Record | undefined; + let uploadPurpose = ""; + let created = false; + + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/documents") && request.method() === "POST") { + uploadPurpose = request.postData() ?? ""; + await fulfillJson(route, { + id: 22, + originalFileName: "estimate.pdf", + scanStatus: "Pending", + reviewStatus: "Processing", + purpose: "UpliftEvidence", + }); + return; + } + if (path.endsWith("/dispatches/7/documents/22/status")) { + await fulfillJson(route, { + id: 22, + originalFileName: "estimate.pdf", + scanStatus: "Passed", + reviewStatus: "Approved", + purpose: "UpliftEvidence", + }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") { + requestBody = request.postDataJSON(); + created = true; + await fulfillJson(route, { + id: 31, + status: "Pending", + noApprovalRequired: false, + }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + poNumber: "PO-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: created + ? [ + { + id: 31, + status: "Pending", + currentNTE: 100, + requestedNTE: 125, + vendorReason: "Replacement compressor", + evidenceDocumentId: 22, + evidenceFileName: "estimate.pdf", + notificationStatus: "Sent", + }, + ] + : [], + }); + return; + } + await fulfillJson(route, []); + }); + + await page.goto("/v/portal-token/dispatch/7"); + await expect(page.getByRole("heading", { name: /DSP-7|PO-7/ })).toBeVisible(); + + await page.getByLabel("New total NTE").fill("125"); + await page.getByLabel("Reason").fill("Replacement compressor"); + await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({ + name: "estimate.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.4\nmock estimate"), + }); + await page.getByRole("button", { name: "Request uplift" }).click(); + + await expect(page.getByText("Pending", { exact: true })).toBeVisible(); + expect(uploadPurpose).toContain('name="purpose"'); + expect(uploadPurpose).toContain("UpliftEvidence"); + expect(requestBody).toMatchObject({ + requestedNTE: 125, + reason: "Replacement compressor", + evidenceDocumentId: 22, + }); + expect(requestBody?.requestKey).toMatch(/^[0-9a-f-]{36}$/i); +}); + +test("vendor can withdraw a pending uplift from the dispatch", async ({ page }) => { + let withdrawnPath = ""; + let pending = true; + + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request/31/withdraw")) { + withdrawnPath = path; + pending = false; + await fulfillJson(route, { id: 31, status: "Withdrawn" }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: [ + { + id: 31, + status: pending ? "Pending" : "Withdrawn", + currentNTE: 100, + requestedNTE: 125, + vendorReason: "Parts", + }, + ], + }); + return; + } + await fulfillJson(route, []); + }); + + page.on("dialog", (dialog) => void dialog.accept()); + await page.goto("/v/portal-token/dispatch/7"); + await page.getByRole("button", { name: "Withdraw request" }).click(); + + await expect(page.getByText("Withdrawn", { exact: true })).toBeVisible(); + expect(withdrawnPath).toBe("/api/vendor-portal/dispatches/7/uplift-request/31/withdraw"); +}); + +test("vendor receives the no-approval result for a request within the current NTE", async ({ + page, +}) => { + let requestBody: Record | undefined; + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request") && request.method() === "POST") { + requestBody = request.postDataJSON(); + await fulfillJson(route, { noApprovalRequired: true, status: "Approved" }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: [], + }); + return; + } + await fulfillJson(route, []); + }); + + await page.goto("/v/portal-token/dispatch/7"); + await page.getByLabel("New total NTE").fill("90"); + await expect(page.getByLabel("Reason")).toHaveCount(0); + await page.getByRole("button", { name: "Request uplift" }).click(); + + await expect( + page.getByText("No approval required — the existing NTE already covers this amount."), + ).toBeVisible(); + expect(requestBody).toMatchObject({ requestedNTE: 90, reason: "" }); + expect(requestBody).not.toHaveProperty("evidenceDocumentId"); +}); + +test("vendor revises a changes-requested uplift using its existing evidence", async ({ page }) => { + let revisionBody: Record | undefined; + let revised = false; + await page.route("**/api/vendor-portal/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("/session")) { + await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" }); + return; + } + if (path.endsWith("/dispatches/7/uplift-request/31/revise")) { + revisionBody = request.postDataJSON(); + revised = true; + await fulfillJson(route, { id: 31, status: "Pending" }); + return; + } + if (path.endsWith("/dispatches/7")) { + await fulfillJson(route, { + id: 7, + dispatchNumber: "DSP-7", + status: "In Progress", + nteAmount: 100, + checklist: [], + comments: [], + signoffs: [], + documents: [], + upliftRequests: [ + { + id: 31, + status: revised ? "Pending" : "ChangesRequested", + currentNTE: 100, + requestedNTE: 125, + vendorReason: "Parts", + decisionNote: "Itemize the estimate", + evidenceDocumentId: 22, + evidenceFileName: "estimate.pdf", + }, + ], + }); + return; + } + await fulfillJson(route, []); + }); + + await page.goto("/v/portal-token/dispatch/7"); + await page.getByRole("button", { name: "Revise" }).click(); + await page.getByLabel("New total NTE").fill("120"); + await page.getByLabel("Reason").fill("Parts itemized in the attached estimate"); + await page.getByRole("button", { name: "Submit revision" }).click(); + + await expect(page.getByText("Pending", { exact: true })).toBeVisible(); + expect(revisionBody).toEqual({ + requestedNTE: 120, + reason: "Parts itemized in the attached estimate", + evidenceDocumentId: 22, + }); +}); + +test("internal approver can approve and request changes with audited notes", async ({ page }) => { + const decisions: Array<{ path: string; body: unknown }> = []; + const statuses = new Map([ + [41, "Pending"], + [42, "Pending"], + ]); + + await page.addInitScript( + (user) => localStorage.setItem("auth", JSON.stringify(user)), + INTERNAL_USER, + ); + await page.route("**/api/Dashboard/Stats", (route) => fulfillJson(route, {})); + await page.route("**/api/User*", (route) => fulfillJson(route, [])); + 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)$/); + if (action && request.method() === "POST") { + const id = Number(action[1]); + statuses.set(id, action[2] === "approve" ? "Approved" : "ChangesRequested"); + decisions.push({ path, body: request.postDataJSON() }); + await fulfillJson(route, { id, status: statuses.get(id) }); + return; + } + + 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", + }, + ], + }); + }); + + await page.goto("/uplifts"); + await expect(page.getByRole("heading", { name: "Uplift Queue" })).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 expect + .poll(() => decisions) + .toEqual([ + { path: "/api/uplifts/41/approve", body: {} }, + { + path: "/api/uplifts/42/request-changes", + body: { note: "Please itemize labor and parts." }, + }, + ]); +}); diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 8bbfdfb8..2bbda41b 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -537,6 +537,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 }, @@ -549,4 +563,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-lock.json b/package-lock.json index bd4d8c90..33da7afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4087,9 +4087,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -4617,9 +4617,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5341,9 +5341,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5652,9 +5652,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", - "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -5671,7 +5671,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index e3204f6b..fc91c435 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/api/api.ts b/src/api/api.ts index d27d3365..51897b61 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,13 +3,13 @@ import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; -import { queryKeys } from "@/infra/query-key/query-key"; import queryClient from "@/lib/query/query-client"; +import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; function clearSessionAndRedirectToLogin(): void { clearAuth(); - queryClient.setQueryData(queryKeys.auth.session(), null); + stopQueryBroadcast(queryClient); if (typeof window !== "undefined" && window.location.pathname !== "/login") { window.location.replace("/login"); 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)} /> + + + Uplift Queue + + + {totalCount} total + + {isFetching && !isLoading && } + + ); +} + +function UpliftQueueFilters({ + status, + tier, + onStatusChange, + onTierChange, +}: { + status: string; + tier: string; + onStatusChange: (value: string) => void; + onTierChange: (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)} + + )} + + + ) : ( + + — + + )} + + {row.requestedAt ? formatDateTime(row.requestedAt) : ""} + + + {Boolean(row.workOrderId) && ( + + )} + {decisionable && ( + <> + + + + + + + + + + + + + + + + + )} + + + + ); +} + +function UpliftQueueTable({ + isLoading, + rows, + onViewWorkOrder, + onDownloadEvidence, + onDecide, +}: { + isLoading: boolean; + rows: UpliftQueueItem[]; + onViewWorkOrder: (row: UpliftQueueItem) => void; + onDownloadEvidence: (row: UpliftQueueItem) => void; + onDecide: (type: UpliftActionType, row: UpliftQueueItem) => 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) => ( + + )) + )} + +
+
+ ); +} + +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; +}) { + return ( + + + {action?.type === "approve" + ? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?` + : action?.type === "reject" + ? "Reject Uplift" + : "Request Changes"} + + + {action?.type === "approve" && ( + + {formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)} + + )} + onNoteChange(e.target.value)} + /> + + + + + + + ); +} + 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<{ - type: "approve" | "deny"; - item: UpliftQueueItem; - } | null>(null); + const [upliftAction, setUpliftAction] = useState(null); const [note, setNote] = useState(""); const listParams = useMemo( @@ -58,39 +349,59 @@ export default function UpliftQueuePage() { const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); const approveUplift = useApproveUplift(); - const denyUplift = useDenyUplift(); + const rejectUplift = useRejectUplift(); + const requestChangesUplift = useRequestChangesUplift(); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; + const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes"; + const decisionPending = + approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending; + + const handleStatusChange = (value: string) => { + setStatus(value); + setPage(0); + }; + const handleTierChange = (value: string) => { + setTier(value); + setPage(0); + }; + + 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: upliftAction.item.id, note: note || undefined }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone }); + } else if (upliftAction.type === "reject") { + rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone }); } else { - if (!note.trim()) return; - denyUplift.mutate( - { id: upliftAction.item.id, note: note.trim() }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone }); } }; + 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 ( - - - Uplift Queue - - - {totalCount} total - - {isFetching && !isLoading && } - - - - - Status - - - - Tier - - - + + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load uplift queue"} )} - - - - - Dispatch - PO - Vendor - Current NTE - Requested - Delta - Tier - Status - Requested At - Actions - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No uplift requests match these filters. - - - - ) : ( - rows.map((row) => ( - - {row.dispatchNumber} - {row.poNumber} - {row.vendorCompanyName} - {formatMoney(row.currentNTE)} - {formatMoney(row.requestedNTE)} - {formatMoney(row.delta)} - - - - - - - {row.requestedAt ? formatDateTime(row.requestedAt) : ""} - - - {Boolean(row.workOrderId) && ( - - )} - {row.status === "Pending" && ( - <> - - - - - - - - - - - - )} - - - - )) - )} - -
-
+ navigate(`/workorders/${r.workOrderId}`)} + onDownloadEvidence={(r) => void downloadEvidence(r)} + onDecide={handleDecide} + /> - setUpliftAction(null)}> - - {upliftAction?.type === "approve" - ? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?` - : "Deny Uplift"} - - - {upliftAction?.type === "approve" && ( - - {formatMoney(upliftAction.item.currentNTE)} →{" "} - {formatMoney(upliftAction.item.requestedNTE)} (+ - {formatMoney(upliftAction.item.delta)}) - - )} - setNote(e.target.value)} - /> - - - - - - +
); } diff --git a/src/app/(protected)/vendor-pos/[id].tsx b/src/app/(protected)/vendor-pos/[id].tsx index 684dc0c7..dc47b341 100644 --- a/src/app/(protected)/vendor-pos/[id].tsx +++ b/src/app/(protected)/vendor-pos/[id].tsx @@ -1,90 +1,82 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { useNavigate, useParams } from "react-router"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, - Typography, -} from "@mui/material"; -import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; -import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail"; +import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography } from "@mui/material"; +import { toast } from "react-toastify"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; -import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; +import { + useApproveUplift, + useRejectUplift, + useRequestChangesUplift, +} from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail"; +import { + PoDetailsPaper, + SitePaper, + UpliftDecisionDialog, + UpliftHistoryPaper, + VendorSummaryPaper, + WorkOrdersPaper, + type UpliftActionType, + type VendorPoUpliftDecision, +} from "@/app/(protected)/vendor-pos/_components/vendor-po-sections"; function formatMoney(value: number | null | undefined): string { if (value == null) return "—"; return `$${Number(value).toFixed(2)}`; } -function InfoRow({ label, children }: { label: string; children: ReactNode }) { - return ( - - - {label} - - - {children == null || children === "" ? "—" : children} - - - ); -} - export default function VendorPoViewPage() { const { id } = useParams(); const navigate = useNavigate(); const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id); const approveUplift = useApproveUplift(id); - const denyUplift = useDenyUplift(id); + const rejectUplift = useRejectUplift(id); + const requestChangesUplift = useRequestChangesUplift(id); - const [upliftAction, setUpliftAction] = useState<{ - type: "approve" | "deny"; - uplift: UpliftRequest; - } | null>(null); + const [upliftAction, setUpliftAction] = useState(null); const [note, setNote] = useState(""); + const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes"; + const decisionPending = + approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending; + + const closeUpliftDialog = () => { + setUpliftAction(null); + setNote(""); + }; + const handleUpliftConfirm = () => { if (!upliftAction) return; + if (noteRequired && !note.trim()) return; + const upliftId = upliftAction.uplift.id; + const onDone = () => { + closeUpliftDialog(); + void refetch(); + }; if (upliftAction.type === "approve") { - approveUplift.mutate( - { id: upliftAction.uplift.id, note: note || undefined }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + approveUplift.mutate({ id: upliftId, note: note || undefined }, { onSuccess: onDone }); + } else if (upliftAction.type === "reject") { + rejectUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone }); } else { - if (!note.trim()) return; - denyUplift.mutate( - { id: upliftAction.uplift.id, note: note.trim() }, - { - onSuccess: () => { - setUpliftAction(null); - setNote(""); - void refetch(); - }, - }, - ); + requestChangesUplift.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onDone }); } }; + const downloadEvidence = async (uplift: UpliftRequest) => { + try { + await upliftsApi.downloadEvidence(uplift.id, uplift.evidenceFileName || "uplift-evidence"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Unable to download evidence."); + } + }; + + const handleDecide = (type: UpliftActionType, uplift: UpliftRequest) => { + setUpliftAction({ type, uplift }); + setNote(""); + }; + if (isLoading) { return ( @@ -141,212 +133,29 @@ export default function VendorPoViewPage() { - - - Vendor - - {vendor ? ( - - {vendor.companyName} - {vendor.contactName} - {vendor.email} - {vendor.phone} - - {[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")} - - {vendor.tradeSpecialties} - - ) : ( - No vendor attached. - )} - - - - - PO Details - - - {dispatch.poNumber} - {dispatch.dispatchNumber} - {dispatch.status} - {formatMoney(dispatch.nteAmount)} - {formatDateTime(dispatch.dispatchedAt)} - {formatDateTime(dispatch.acknowledgedAt)} - {toDateInputValue(dispatch.scheduledDate) || "—"} - {toDateInputValue(dispatch.completedDate) || "—"} - {formatDateTime(dispatch.verifiedAt)} - {dispatch.emailSent ? "Yes" : "No"} - - {Boolean(dispatch.description) && ( - - - Description - - - {dispatch.description} - - - )} - - - {dispatch.primaryLocation != null && ( - - - Site - - - {dispatch.primaryLocation.name} - - {[ - dispatch.primaryLocation.address, - dispatch.primaryLocation.city, - dispatch.primaryLocation.state, - dispatch.primaryLocation.zipCode, - ] - .filter(Boolean) - .join(", ")} - - - - )} - - - - Linked Work Orders ({dispatch.workOrders.length}) - - {dispatch.workOrders.length === 0 ? ( - No work orders linked. - ) : ( - - - - WO # - Title - Trade - Status - Location - - - - {dispatch.workOrders.map((wo) => ( - - - - - {wo.title} - {wo.trade} - {wo.status} - {wo.locationName} - - ))} - -
- )} -
- - - - NTE Uplift History ({dispatch.upliftRequests.length}) - - {dispatch.upliftRequests.length === 0 ? ( - No uplift requests on this PO. - ) : ( - - {dispatch.upliftRequests.map((u) => ( - - - - {formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)} - - (Δ {formatMoney(u.delta)}, Tier {u.requiredTier}) - - - - - {Boolean(u.vendorReason) && ( - - Reason: {u.vendorReason} - - )} - {u.status === "Pending" && ( - - - - - )} - - ))} - - )} - + + + + navigate(`/workorders/${workOrderId}`)} + /> + void downloadEvidence(uplift)} + onDecide={handleDecide} + />
- setUpliftAction(null)}> - - {upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"} - - - setNote(e.target.value)} - required={upliftAction?.type === "deny"} - /> - - - - - - +
); } diff --git a/src/app/(protected)/vendor-pos/_components/vendor-po-sections.tsx b/src/app/(protected)/vendor-pos/_components/vendor-po-sections.tsx new file mode 100644 index 00000000..7e6ecaf1 --- /dev/null +++ b/src/app/(protected)/vendor-pos/_components/vendor-po-sections.tsx @@ -0,0 +1,341 @@ +import { type ReactNode } from "react"; +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import type { UpliftRequest } from "@/domain/uplifts/types/uplift"; +import type { + VendorPoDetail, + VendorPoLocation, + VendorPoVendor, + VendorPoWorkOrder, +} from "@/domain/vendor-pos/types/vendor-po"; +import { formatDateTime, toDateInputValue } from "@/lib/time-utils"; + +export type UpliftActionType = "approve" | "reject" | "request-changes"; + +export type VendorPoUpliftDecision = { type: UpliftActionType; uplift: UpliftRequest }; + +function formatMoney(value: number | null | undefined): string { + if (value == null) return "—"; + return `$${Number(value).toFixed(2)}`; +} + +function InfoRow({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children == null || children === "" ? "—" : children} + + + ); +} + +export function VendorSummaryPaper({ vendor }: { vendor: VendorPoVendor | null }) { + return ( + + + Vendor + + {vendor ? ( + + {vendor.companyName} + {vendor.contactName} + {vendor.email} + {vendor.phone} + + {[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")} + + {vendor.tradeSpecialties} + + ) : ( + No vendor attached. + )} + + ); +} + +export function PoDetailsPaper({ dispatch }: { dispatch: VendorPoDetail }) { + return ( + + + PO Details + + + {dispatch.poNumber} + {dispatch.dispatchNumber} + {dispatch.status} + {formatMoney(dispatch.nteAmount)} + {formatDateTime(dispatch.dispatchedAt)} + {formatDateTime(dispatch.acknowledgedAt)} + {toDateInputValue(dispatch.scheduledDate) || "—"} + {toDateInputValue(dispatch.completedDate) || "—"} + {formatDateTime(dispatch.verifiedAt)} + {dispatch.emailSent ? "Yes" : "No"} + + {Boolean(dispatch.description) && ( + + + Description + + + {dispatch.description} + + + )} + + ); +} + +export function SitePaper({ location }: { location: VendorPoLocation | null }) { + if (location == null) return null; + return ( + + + Site + + + {location.name} + + {[location.address, location.city, location.state, location.zipCode] + .filter(Boolean) + .join(", ")} + + + + ); +} + +export function WorkOrdersPaper({ + workOrders, + onView, +}: { + workOrders: VendorPoWorkOrder[]; + onView: (workOrderId: string | number) => void; +}) { + return ( + + + Linked Work Orders ({workOrders.length}) + + {workOrders.length === 0 ? ( + No work orders linked. + ) : ( + + + + WO # + Title + Trade + Status + Location + + + + {workOrders.map((wo) => ( + + + + + {wo.title} + {wo.trade} + {wo.status} + {wo.locationName} + + ))} + +
+ )} +
+ ); +} + +function UpliftHistoryItem({ + uplift, + onDownload, + onDecide, +}: { + uplift: UpliftRequest; + onDownload: (uplift: UpliftRequest) => void; + onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void; +}) { + const decisionable = uplift.status === "Pending"; + return ( + + + + {formatMoney(uplift.currentNTE)} → {formatMoney(uplift.requestedNTE)} + + (Tier {uplift.requiredTier}) + + + + + {Boolean(uplift.expiresAt) && ( + + Expires {formatDateTime(uplift.expiresAt)} + + )} + {Boolean(uplift.vendorReason) && ( + + Reason: {uplift.vendorReason} + + )} + {uplift.evidenceDocumentId != null && ( + + + Evidence:{" "} + {uplift.evidenceFileName || `Document #${uplift.evidenceDocumentId}`} + + + + )} + {Boolean(uplift.decisionNote) && ( + + Decision: {uplift.decisionNote} + + )} + {uplift.notificationStatus === "Error" && Boolean(uplift.notificationError) && ( + + Notification error: {uplift.notificationError} + + )} + {decisionable && ( + + + + + + )} + + ); +} + +export function UpliftHistoryPaper({ + upliftRequests, + onDownload, + onDecide, +}: { + upliftRequests: UpliftRequest[]; + onDownload: (uplift: UpliftRequest) => void; + onDecide: (type: UpliftActionType, uplift: UpliftRequest) => void; +}) { + return ( + + + NTE Uplift History ({upliftRequests.length}) + + {upliftRequests.length === 0 ? ( + No uplift requests on this PO. + ) : ( + + {upliftRequests.map((uplift) => ( + + ))} + + )} + + ); +} + +export function UpliftDecisionDialog({ + action, + note, + noteRequired, + decisionPending, + onNoteChange, + onClose, + onConfirm, +}: { + action: VendorPoUpliftDecision | null; + note: string; + noteRequired: boolean; + decisionPending: boolean; + onNoteChange: (value: string) => void; + onClose: () => void; + onConfirm: () => void; +}) { + return ( + + + {action?.type === "approve" + ? "Approve Uplift" + : action?.type === "reject" + ? "Reject Uplift" + : "Request Changes"} + + + onNoteChange(e.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx new file mode 100644 index 00000000..eadbf15d --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx @@ -0,0 +1,248 @@ +import type { Dispatch, SetStateAction } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; +import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; +import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material"; +import type { + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; +import { FilterSection } from "./vendor-filters-drawer-section"; + +interface VendorFiltersDrawerFieldsProps { + draft: VendorFilters; + setDraft: Dispatch>; + options: { + companies: VendorFacetCompany[]; + trades: string[]; + locations: VendorFacetLocation[]; + jobBuckets: VendorFacetJobBucket[]; + }; +} + +function CompanyFilterFields({ + draft, + setDraft, + companyOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + companyOptions: VendorFacetCompany[]; +}) { + return ( + }> + + + Select vendor companies + + + + (typeof option === "string" ? option : option.name)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.name === value.name + } + value={companyOptions.filter((option) => draft.companies.includes(option.name))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetCompany)[]).map((entry) => + typeof entry === "string" ? entry : entry.name, + ); + setDraft((current) => ({ ...current, companies: next })); + }} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( +
  • + + {option.name} +
  • + ); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.name; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilterFields({ + draft, + setDraft, + tradeOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + tradeOptions: string[]; +}) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function LocationFilterFields({ + draft, + setDraft, + locationOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + locationOptions: VendorFacetLocation[]; +}) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={locationOptions.filter((option) => draft.locations.includes(option.label))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetLocation)[]).map((entry) => + typeof entry === "string" ? entry : entry.label, + ); + setDraft((current) => ({ ...current, locations: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobBucketFilterFields({ + draft, + setDraft, + jobBucketOptions, +}: { + draft: VendorFilters; + setDraft: Dispatch>; + jobBucketOptions: VendorFacetJobBucket[]; +}) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} + onChange={(_event, value) => { + const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => + typeof entry === "string" ? entry : entry.id, + ); + setDraft((current) => ({ ...current, jobBuckets: next })); + }} + renderValue={(value, getItemProps) => + value.map((option, index) => { + const label = typeof option === "string" ? option : option.label; + const tagProps = getItemProps({ index }); + const { key, ...chipProps } = tagProps; + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFiltersDrawerFields({ + draft, + setDraft, + options, +}: VendorFiltersDrawerFieldsProps) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx new file mode 100644 index 00000000..9d7c2997 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Stack, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; + +export function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index ae4b51ba..86cb612a 100644 --- a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx @@ -1,32 +1,9 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; -import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +import { useEffect, useMemo, useState } from "react"; import CloseIcon from "@mui/icons-material/Close"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; -import TagOutlinedIcon from "@mui/icons-material/TagOutlined"; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - Checkbox, - Chip, - Drawer, - IconButton, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { - VendorFacets, - VendorFacetCompany, - VendorFacetJobBucket, - VendorFacetLocation, -} from "@/domain/vendors/types/vendor"; +import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields"; interface VendorFiltersDrawerProps { open: boolean; @@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps { const DRAWER_WIDTH = 360; -function FilterSection({ - title, - icon, - children, -}: { - title: string; - icon: ReactNode; - children: ReactNode; -}) { - return ( - - } aria-controls={`${title}-filters-content`}> - - {icon} - - {title} - - - - - {children} - - - ); -} - export function VendorFiltersDrawer({ open, onClose, @@ -125,7 +76,7 @@ export function VendorFiltersDrawer({ Filters - {activeCount > 0 && } + {Boolean(activeCount > 0) && } @@ -133,165 +84,16 @@ export function VendorFiltersDrawer({ - }> - - - Select vendor companies - - - - (typeof option === "string" ? option : option.name)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.name === value.name - } - value={companyOptions.filter((option) => draft.companies.includes(option.name))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetCompany)[]).map((entry) => - typeof entry === "string" ? entry : entry.name, - ); - setDraft((current) => ({ ...current, companies: next })); - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( -
  • - - {option.name} -
  • - ); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.name; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> -
    - - }> - - setDraft((current) => ({ ...current, trades: value as string[] })) - } - renderValue={(value, getItemProps) => - value.map((option, index) => { - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.label === value.label - } - value={locationOptions.filter((option) => draft.locations.includes(option.label))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetLocation)[]).map((entry) => - typeof entry === "string" ? entry : entry.label, - ); - setDraft((current) => ({ ...current, locations: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - - - }> - (typeof option === "string" ? option : option.label)} - isOptionEqualToValue={(option, value) => - typeof option === "string" || typeof value === "string" - ? option === value - : option.id === value.id - } - value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))} - onChange={(_event, value) => { - const next = (value as (string | VendorFacetJobBucket)[]).map((entry) => - typeof entry === "string" ? entry : entry.id, - ); - setDraft((current) => ({ ...current, jobBuckets: next })); - }} - renderValue={(value, getItemProps) => - value.map((option, index) => { - const label = typeof option === "string" ? option : option.label; - const tagProps = getItemProps({ index }); - const { key, ...chipProps } = tagProps; - return ; - }) - } - renderInput={(params) => ( - - )} - /> - +
    void }) { + return ( + + Failed to load vendor company. Try again. + + + + + ); +} + +function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } { + if (isEdit) { + return { + title: "Edit Vendor Company", + subtitle: "Update company details and technicians in one save.", + }; + } + return { + title: "Add Vendor Company", + subtitle: "Register a company and add zero, one, or multiple technicians.", + }; +} + +function submitLabel(isSaving: boolean, isEdit: boolean): string { + if (isSaving) return "Saving..."; + return isEdit ? "Save" : "Create"; +} + export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) { const navigate = useNavigate(); const isEdit = vendorId !== undefined || companyId !== undefined; @@ -94,26 +125,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa } if (isEdit && form.isError) { - return ( - - Failed to load vendor company. Try again. - - - - - ); + return navigate("/vendors")} />; } if (isEdit && !form.roster) { return null; } - const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company"; - const subtitle = isEdit - ? "Update company details and technicians in one save." - : "Register a company and add zero, one, or multiple technicians."; + const { title, subtitle } = rosterPageCopy(isEdit); const companySelectionProps = isEdit ? {} : { @@ -122,6 +141,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa onSelectCompany: form.selectCompany, onClearSelectedCompany: form.clearSelectedCompany, }; + const showPortalToken = Boolean(isEdit && vendorId); return ( @@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa {...companySelectionProps} /> - {isEdit && vendorId !== undefined && vendorId !== "" && ( + {showPortalToken && vendorId !== undefined && ( )} @@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa Cancel diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index e3e7cfa5..e37131c4 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -48,7 +48,9 @@ export function VendorsListToolbar({ {globalCount} vendor entries · {activeCount} active - {isFetching && !isLoading && } + {isFetching && !isLoading && ( + + )} onSearchChange(e.target.value)} placeholder="Search name, company, trade..." className="min-w-[260px]" + aria-label="Search vendors" /> +
    + + +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx new file mode 100644 index 00000000..91b9792c --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-generated.tsx @@ -0,0 +1,35 @@ +import { CheckCircle2, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterGeneratedProps = { + generatedAt: string; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooterGenerated({ + generatedAt, + onDownloadPdf, +}: CompDocDialogFooterGeneratedProps) { + return ( + <> + + Generated {generatedAt} + +
    + +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx new file mode 100644 index 00000000..da1976bb --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-not-required.tsx @@ -0,0 +1,47 @@ +import { Ban, Sparkles, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterNotRequiredProps = { + onUndo: () => void; +}; + +export function CompDocDialogFooterNotRequired({ onUndo }: CompDocDialogFooterNotRequiredProps) { + return ( + <> + + Marked{" "} + not required for this work order + +
    + + +
    + + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx index 08a9cb20..7a492060 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx @@ -4,13 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; type CompDocDialogHeaderProps = { - info: WorkOrderTableRow; + row: WorkOrderTableRow; cfg: CompletionDocStateConfig; isUploading: boolean; onClose: () => void; }; -export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { +export function CompDocDialogHeader({ row, cfg, isUploading, onClose }: CompDocDialogHeaderProps) { return (
    WO{" "} - #{info.woNumber} + #{row.woNumber} {" · "} - {serviceTitle(info)} + {serviceTitle(row)}
    diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index f87fbb78..030e63df 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -1,15 +1,23 @@ -import type { MouseEvent } from "react"; +import { useRef, useState, type MouseEvent } from "react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { toast } from "react-toastify"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { Text } from "@/components/ui/text"; import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { + formatMediaSizeLimit, + isWorkOrderDocumentsReadOnly, + isWorkOrderFileTooLarge, +} from "@/domain/work-orders/utils/work-order-media-limits"; +import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog"; import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; import { CompDocDialogHeader } from "./comp-doc-dialog-header"; import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links"; import { CompDocPreview } from "./comp-doc-preview"; import { CompDocSidebar } from "./comp-doc-sidebar"; -import { useCompDocDialog } from "./use-comp-doc-dialog"; type CompDocDialogProps = { row: WorkOrderTableRow; @@ -20,6 +28,59 @@ type CompDocDialogProps = { onDateCompletedChange?: (date: string) => void | Promise; }; +type UseCompDocPdfUploadArgs = { + workOrderId: string | number; + rowVersion: string; + onUploaded: () => void | Promise; +}; + +function rejectCompDocPdf(file: File, rowVersion: string): string | null { + if (isWorkOrderFileTooLarge(file)) { + return `File exceeds the ${formatMediaSizeLimit()} limit.`; + } + if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { + return "Please upload a PDF file."; + } + if (!rowVersion) { + return "Missing work order version. Refresh and try again."; + } + return null; +} + +function useCompDocPdfUpload({ workOrderId, rowVersion, onUploaded }: UseCompDocPdfUploadArgs) { + const uploadCompletionMutation = useUploadCompletionDoc(workOrderId); + const pdfInputRef = useRef(null); + const [isFinalizing, setIsFinalizing] = useState(false); + + function handleUploadPdf(files: FileList | null) { + const file = files?.[0]; + if (!file) return; + + const rejection = rejectCompDocPdf(file, rowVersion); + if (rejection) { + toast.error(rejection); + return; + } + + uploadCompletionMutation.mutate( + { file, workOrderVersion: rowVersion }, + { + onSuccess: () => { + // Keep close/actions disabled across the gap between upload settle and docStatus persist. + setIsFinalizing(true); + void Promise.resolve(onUploaded()).finally(() => setIsFinalizing(false)); + }, + }, + ); + } + + return { + pdfInputRef, + handleUploadPdf, + isUploading: uploadCompletionMutation.isPending || isFinalizing, + }; +} + export function CompDocDialog({ row, open, @@ -28,14 +89,33 @@ export function CompDocDialog({ onSave, onDateCompletedChange, }: CompDocDialogProps) { - const dialog = useCompDocDialog({ - row, - open, - data, - onSave, - onDateCompletedChange, + const dialog = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange }); + const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open); + + const templateUrl = boardDetail?.completion.template?.templateUrl; + const signOffAttachment = boardDetail?.completion.signOffAttachment || null; + const rowVersion = boardDetail?.info.rowVersion || row.rowVersion; + const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status); + + // Uploading a signed PDF finalizes through the same Generate flow, so the + // completedDate/address prerequisites still persist before docStatus Yes. + const upload = useCompDocPdfUpload({ + workOrderId: row.id, + rowVersion, + onUploaded: dialog.handleGenerate, }); - const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false); + + // A signed PDF on file means the document exists even if docState has not caught up. + const isGenerated = dialog.isGenerated || Boolean(signOffAttachment); + const handleClose = upload.isUploading ? undefined : () => onOpenChange(false); + + function handleDownloadPdf() { + if (signOffAttachment) { + window.open(signOffAttachment, "_blank", "noopener,noreferrer"); + return; + } + dialog.handleDownloadPdf(); + } return ( - - Completion Document — WO #{dialog.info.woNumber} - + Completion Document — WO #{row.woNumber} Generate, save or mark not-required the completion document for this work order.
    handleClose?.()} />
    @@ -112,18 +192,18 @@ export function CompDocDialog({
    diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx new file mode 100644 index 00000000..910b7227 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast.tsx @@ -0,0 +1,29 @@ +import { CheckCircle2 } from "lucide-react"; +import { toast } from "react-toastify"; + +export function showCompDocDraftSavedToast() { + toast( +
    + Draft saved +
    , + { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + closeButton: false, + icon: false, + className: "!min-h-0 !bg-transparent !p-0 !shadow-none", + }, + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx new file mode 100644 index 00000000..8e9261e2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-editable-row.tsx @@ -0,0 +1,54 @@ +import type { CSSProperties } from "react"; + +const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1"; + +const COMP_DOC_INPUT_STYLE: CSSProperties = { + fontSize: 13, + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + background: "var(--color-bg-surface)", +}; + +const COMP_DOC_INPUT_DISABLED_STYLE: CSSProperties = { + opacity: 0.6, + cursor: "default", + pointerEvents: "none", +}; + +type CompDocEditableRowProps = { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + placeholder?: string; + disabled?: boolean; +}; + +export function CompDocEditableRow({ + label, + value, + onChange, + type = "text", + placeholder = "", + disabled = false, +}: CompDocEditableRowProps) { + return ( +
    +
    + {label} +
    + onChange(e.target.value)} + readOnly={disabled} + placeholder={placeholder} + className={COMP_DOC_INPUT_CLS} + style={{ + ...COMP_DOC_INPUT_STYLE, + ...(disabled ? COMP_DOC_INPUT_DISABLED_STYLE : {}), + }} + /> +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts new file mode 100644 index 00000000..f5c0f2ba --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-footer-btn-sx.ts @@ -0,0 +1,8 @@ +export const COMP_DOC_FOOTER_BTN_SX = { + minHeight: 28, + height: 28, + fontSize: 12, + px: 1.25, + py: 0.5, + gap: 0.75, +} as const; diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx new file mode 100644 index 00000000..ab3cbf04 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-not-required-stamp.tsx @@ -0,0 +1,25 @@ +import { Ban } from "lucide-react"; + +export function CompDocNotRequiredStamp() { + return ( +
    +
    + NOT REQUIRED +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx new file mode 100644 index 00000000..f6deb5e5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-notes-field.tsx @@ -0,0 +1,50 @@ +type CompDocNotesFieldProps = { + notes: string; + onNotesChange: (value: string) => void; + isGenerated: boolean; +}; + +export function CompDocNotesField({ notes, onNotesChange, isGenerated }: CompDocNotesFieldProps) { + return ( +
    + +