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/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..ad1a89e8 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -306,11 +306,18 @@ test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); const state = await mockVendorApi(page); await page.goto("/vendors"); - await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); - await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible(); + const heading = page.getByRole("heading", { name: "Vendors / Technicians" }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("font-family", /Montserrat/); + await expect(heading).toHaveCSS("font-size", "30px"); + await expect(heading).toHaveCSS("font-weight", "800"); + await expect(heading).toHaveCSS("line-height", "33px"); + await expect(heading).toHaveCSS("letter-spacing", "-0.6px"); + await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", @@ -323,52 +330,58 @@ test.describe("Vendor directory prototype parity", () => { "Phone", "Email", "Total Jobs", - "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); + const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox(); + expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 }); + const tableContainerBox = await page.locator("table").locator("..").boundingBox(); + expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 }); + expect(tableContainerBox?.height).toBeCloseTo(738, 0); + expect((await page.locator("table").boundingBox())?.width).toBe(1550); + expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe( + 47, + ); + expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52); await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); - await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible(); + await expect(page.getByText("2 active technicians")).toBeVisible(); + await expect(page.getByText("2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); - await page.getByPlaceholder("Search name, company, trade...").fill("metro"); + await page.getByPlaceholder("Search name, company, trade…").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); - await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); + await page.getByPlaceholder("Search name, company, trade…").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); - await page.getByPlaceholder("Search name, company, trade...").clear(); + await page.getByPlaceholder("Search name, company, trade…").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); - await page.getByRole("button", { name: "Apply" }).click(); + await page.getByRole("button", { name: "Apply filters" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); - await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible(); + await expect(page.getByText("1 technician · filtered by")).toBeVisible(); + await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); - await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( - "history will be preserved", - ); - await page.getByRole("button", { name: "Deactivate", exact: true }).click(); - await expect.poll(() => state.deletedId).toBe("1"); - await page.getByRole("tab", { name: "Inactive (1)" }).click(); - await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); + await expect(page.getByText("No inactive technicians found")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); @@ -378,33 +391,35 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - await page.getByRole("button", { name: "+ Add Vendor" }).click(); - await expect( - page.getByText("Register a company and add zero, one, or multiple technicians."), - ).toBeVisible(); + await page.getByRole("button", { name: "Add Vendor", exact: true }).click(); + await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); - await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); - await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue( + await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); + await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", ); - await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); - await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); + await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St"); + await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue( + "https://maps.google.com/gateway", + ); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); - await page.getByLabel("Technician Name").last().fill("New Technician"); + await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); - await page.getByLabel("Notes").fill("Created in browser E2E"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); - await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); + await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); + await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", @@ -429,7 +444,7 @@ test.describe("Vendor directory prototype parity", () => { await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); + await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( @@ -438,9 +453,9 @@ test.describe("Vendor directory prototype parity", () => { await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByLabel("Notes").fill("Updated in browser E2E"); - await page.getByRole("button", { name: "Save" }).click(); + await expect(page).toHaveURL(/\/vendors$/); + await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E"); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); @@ -448,18 +463,18 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); - await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); - await detailDrawer.getByRole("button", { name: "Edit" }).click(); - await expect(page).toHaveURL(/\/vendors\/edit\/1$/); - await page.getByRole("switch", { name: "Inactive" }).first().click(); - await page.getByRole("button", { name: "Save" }).click(); + await detailDrawer.getByRole("switch", { name: "Active status" }).click(); + await detailDrawer.getByRole("button", { name: "Save changes" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); + await detailDrawer.getByRole("button", { name: "Close drawer" }).click(); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); @@ -468,7 +483,7 @@ test.describe("Vendor directory prototype parity", () => { const state = await mockVendorApi(page); await page.goto("/vendors"); - const addButton = page.getByRole("button", { name: "+ Add Vendor" }); + const addButton = page.getByRole("button", { name: "Add Vendor", exact: true }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); @@ -477,10 +492,12 @@ test.describe("Vendor directory prototype parity", () => { await expect(addButton).toBeFocused(); await addButton.click(); - await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Add Vendor", exact: true }).last(), + ).toBeEnabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); - await page.getByLabel("Company Phone").fill("3145550199"); - await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); + await page.getByLabel("Company Phone (optional)").fill("3145550199"); + await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); @@ -506,7 +523,8 @@ test.describe("Vendor directory prototype parity", () => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); @@ -517,7 +535,8 @@ test.describe("Vendor directory prototype parity", () => { const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); - await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); + await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click(); + await page.getByRole("switch", { name: "Active status" }).click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { @@ -537,10 +556,25 @@ 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 }, ); + await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png"); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); @@ -549,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-lock.json b/package-lock.json index c732e572..426dc13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4048,9 +4048,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": [ { @@ -4578,9 +4578,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": [ { @@ -5302,9 +5302,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", @@ -5561,9 +5561,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", @@ -5580,7 +5580,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 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)} /> + + + 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/use-vendor-deactivation.ts b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts index 9b6f3b0d..b63291be 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-deactivation.ts @@ -15,7 +15,7 @@ export interface VendorDeactivationState { confirm: () => void; } -export function useVendorDeactivation(): VendorDeactivationState { +export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState { const [target, setTarget] = useState(null); const [error, setError] = useState(null); const deleteVendor = useDeleteVendor(); @@ -39,7 +39,10 @@ export function useVendorDeactivation(): VendorDeactivationState { if (!target || target.id == null) return; setError(null); deleteVendor.mutate(target.id, { - onSuccess: () => setTarget(null), + onSuccess: () => { + setTarget(null); + onSuccess?.(); + }, onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"), }); }; diff --git a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts index 9385a834..fdc90685 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-list-state.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-list-state.ts @@ -31,7 +31,7 @@ export interface VendorListState { export function useVendorListState(): VendorListState { const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(12); + const [pageSize, setPageSize] = useState(48); const [isActive, setIsActive] = useState(true); const [appliedFilters, setAppliedFilters] = useState(emptyVendorFilters); diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index f6ea637a..6bf89b1f 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, + emptyRosterTechnician, vendorCompanyRosterSchema, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -20,11 +21,30 @@ import type { VendorRosterConflict, } from "@/domain/vendors/types/vendor"; +function withoutBlankNewTechnicians( + values: VendorCompanyRosterFormValues, +): VendorCompanyRosterFormValues { + return { + ...values, + technicians: values.technicians.filter( + (technician) => + technician.id != null || + Boolean( + technician.contactName.trim() || + technician.phone.trim() || + technician.email.trim() || + technician.tradeSpecialties.trim(), + ), + ), + }; +} + export interface VendorRosterFormProps { mode: "create" | "update"; vendorId?: string | number; companyId?: string | number; onSuccess?: (roster: VendorCompanyRoster) => void; + startWithTechnician?: boolean; } export interface VendorRosterForm { @@ -55,6 +75,7 @@ export function useVendorRosterForm({ vendorId, companyId, onSuccess, + startWithTechnician = false, }: VendorRosterFormProps): VendorRosterForm { const query = useVendorCompanyRoster( mode === "update" ? vendorId : undefined, @@ -69,9 +90,16 @@ export function useVendorRosterForm({ const trades = useMemo(() => facets?.trades ?? [], [facets]); const resolver = useVendorRosterResolver(routeRoster); + const createDefaults = useMemo( + () => ({ + ...emptyVendorCompanyRosterForm, + technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [], + }), + [startWithTechnician], + ); const form = useForm({ resolver, - defaultValues: emptyVendorCompanyRosterForm, + defaultValues: createDefaults, values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined, mode: "onChange", }); @@ -96,10 +124,10 @@ export function useVendorRosterForm({ }, [routeRoster, watched]); const resetForm = useCallback(() => { - reset(emptyVendorCompanyRosterForm); + reset(createDefaults); resetSelection(); clearConflict(); - }, [clearConflict, reset, resetSelection]); + }, [clearConflict, createDefaults, reset, resetSelection]); const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; const isUpdate = mode === "update" || selection.selectedRoster != null; @@ -109,7 +137,7 @@ export function useVendorRosterForm({ save.mutate( { mode: isUpdate ? "update" : "create", - values: formValues, + values: withoutBlankNewTechnicians(formValues), companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index d0025662..31804a3c 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -8,8 +8,8 @@ import { DialogContent, DialogTitle, Stack, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; import { VendorRosterFormFields } from "./vendor-roster-form-fields"; import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; @@ -21,7 +21,11 @@ interface VendorCreateModalProps { } export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { - const form = useVendorRosterForm({ mode: "create", onSuccess: onClose }); + const form = useVendorRosterForm({ + mode: "create", + onSuccess: onClose, + startWithTechnician: true, + }); const { resetForm } = form; useEffect(() => { @@ -33,17 +37,24 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { open={open} onClose={form.isSaving ? undefined : onClose} fullWidth - maxWidth="md" + maxWidth="sm" aria-labelledby="vendor-create-dialog-title" - slotProps={{ paper: { role: "dialog" } }} + slotProps={{ + paper: { + role: "dialog", + sx: { maxHeight: "88vh", borderRadius: 2 }, + }, + }} > - Add Vendor Company - - Register a company and add zero, one, or multiple technicians. - + + Add Vendor + + + Register a vendor company and its technicians. + - + {form.loadErrorMessage !== null && ( @@ -73,9 +84,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { variant="contained" type="submit" form="vendor-create-form" - disabled={form.isSaving || !form.isValid} + disabled={form.isSaving} > - {form.isSaving ? : "Add Vendor Company"} + {form.isSaving ? : "Add Vendor"} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index ad7ffa29..74d6ef02 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -1,5 +1,5 @@ -import { useEffect, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useEffect, useState, type ReactNode } from "react"; +import { Controller, useWatch } from "react-hook-form"; import CloseIcon from "@mui/icons-material/Close"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import LaunchIcon from "@mui/icons-material/Launch"; @@ -13,15 +13,22 @@ import { Drawer, IconButton, Link, - List, - ListItem, Stack, - Typography, + Switch, } from "@mui/material"; +import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert"; +import { VendorRosterFormFields } from "./vendor-roster-form-fields"; +import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error"; +import { useVendorRosterForm } from "./use-vendor-roster-form"; +import { Text } from "@/components/ui/text"; +import type { + VendorCompanyRoster, + VendorListItem, + VendorRosterTechnician, +} from "@/domain/vendors/types/vendor"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; -import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor"; -const DRAWER_WIDTH = 440; +const DRAWER_WIDTH = 420; function splitTrades(value: string | undefined): string[] { return (value ?? "") @@ -33,106 +40,45 @@ function splitTrades(value: string | undefined): string[] { interface VendorDetailDrawerProps { open: boolean; vendor: VendorListItem | null; + startInEdit?: boolean; + onRequestDeactivation?: (vendor: VendorListItem) => void; onClose: () => void; } function SectionTitle({ children }: { children: ReactNode }) { return ( - {children} - + ); } -function Field({ label, value }: { label: string; value: string }) { - const display = value && value.trim() !== "" ? value : "—"; +function Field({ label, value }: { label: string; value?: string }) { + const display = !value || value.trim() === "" ? "—" : value; return ( - + {label} - - {display} + + {display} ); } -function TechnicianEntry({ - contactName, - phone, - email, - preferredContact, - tradeSpecialties, - isActive, - totalJobs, -}: { - contactName: string; - phone: string; - email: string; - preferredContact?: string; - tradeSpecialties: string; - isActive: boolean; - totalJobs: number; -}) { - const trades = splitTrades(tradeSpecialties); - return ( - - - - - {contactName || "—"} - - - - {totalJobs} jobs - - - - {Boolean(phone) && ( - - {phone} - - )} - {Boolean(email) && ( - - {email} - - )} - - - {Boolean(preferredContact) && ( - - Prefers {preferredContact} - - )} - {trades.map((trade, index) => ( - - ))} - - - - ); -} - function DrawerHeader({ roster, vendor, + technician, onClose, }: { roster?: VendorCompanyRoster; vendor: VendorListItem | null; + technician?: VendorRosterTechnician; onClose: () => void; }) { return ( @@ -141,23 +87,18 @@ function DrawerHeader({ sx={{ alignItems: "center", justifyContent: "space-between", - px: 3, + px: 2.5, py: 2, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, }} > - - {roster?.name || vendor?.companyName || "Vendor company"} - - - {roster ? `${roster.technicians.length} technician(s)` : "Vendor details"} - + + {technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"} + + + {roster?.name || vendor?.companyName || "Vendor details"} + @@ -167,18 +108,19 @@ function DrawerHeader({ } function CompanySection({ roster }: { roster: VendorCompanyRoster }) { - const mapsUrl = roster.googleMapsUrl; + const address = [roster.address, roster.city, roster.state, roster.zip] + .filter(Boolean) + .join(", "); + return ( + - - - {Boolean(mapsUrl) && ( + + + {Boolean(roster.googleMapsUrl) && ( - Technicians - {roster.technicians.length === 0 ? ( - - No technicians assigned to this company. - - ) : ( - - {roster.technicians.map((technician) => ( - + + + + Trade + + + {trades.length > 0 ? ( + trades.map((trade, index) => ( + + )) + ) : ( + — + )} + + + + + {technician.totalJobs ?? 0} + + Total Jobs + + + + + Status + + - ))} - - )} +
+ +
); } -function DrawerBody({ roster }: { roster: VendorCompanyRoster }) { +function DrawerBody({ + roster, + technician, +}: { + roster: VendorCompanyRoster; + technician?: VendorRosterTechnician; +}) { return ( - - Company - + + + Company + + - + {technician ? ( + + ) : ( + + No technicians assigned to this company. + + )} {Boolean(roster.notes) && ( <> Notes - + {roster.notes} - + )} @@ -246,8 +218,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { `1px solid ${theme.palette.divider}`, alignItems: "center", justifyContent: "flex-end", @@ -260,8 +232,143 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) { ); } -export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) { - const navigate = useNavigate(); +function DrawerEditor({ + vendor, + onClose, + onSaved, + onRequestDeactivation = () => undefined, +}: { + vendor: VendorListItem; + onClose: () => void; + onSaved: () => void; + onRequestDeactivation: (vendor: VendorListItem) => void; +}) { + const form = useVendorRosterForm({ + mode: "update", + vendorId: vendor.id ?? undefined, + companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined, + onSuccess: onSaved, + }); + const roster = form.roster; + const technicians = useWatch({ control: form.control, name: "technicians" }) ?? []; + const selectedIndex = technicians.findIndex( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + ); + const selectedTotalJobs = + roster?.technicians.find( + (technician) => technician.id != null && String(technician.id) === String(vendor.id), + )?.totalJobs ?? vendor.totalJobs; + + if (form.isLoading) { + return ; + } + if (form.isError || !roster) { + return ( + + Failed to load vendor company. Try again. + + ); + } + + return ( + + + {form.loadErrorMessage !== null && ( + + )} + {form.conflict !== null && ( + + )} + + {selectedIndex >= 0 && ( + <> + + + + {selectedTotalJobs} + + Total Jobs + + + ( + + {field.value ? "Active" : "Inactive"} + { + if (field.value && !checked) onRequestDeactivation(vendor); + else field.onChange(checked); + }} + /> + + )} + /> + + + )} + + + + + + + ); +} + +function resolveTechnician( + roster: VendorCompanyRoster | undefined, + vendor: VendorListItem | null, +): VendorRosterTechnician | undefined { + if (!roster || !vendor) return undefined; + + const match = roster.technicians.find( + (technician) => + vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id), + ); + if (match) return match; + if (!vendor.contactName) return undefined; + + return { + id: vendor.id ?? undefined, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + tradeSpecialties: vendor.tradeSpecialties, + isActive: vendor.isActive, + totalJobs: vendor.totalJobs, + }; +} + +export function VendorDetailDrawer({ + open, + vendor, + startInEdit = false, + onRequestDeactivation = () => undefined, + onClose, +}: VendorDetailDrawerProps) { + const [isEditing, setIsEditing] = useState(startInEdit); const vendorId = vendor?.id; const companyId = vendor?.companyId; const hasVendorId = vendorId != null; @@ -274,25 +381,30 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer !hasVendorId ? (companyId ?? undefined) : undefined, ); - const editHref = hasVendorId - ? `/vendors/edit/${vendorId}` - : `/vendors/company/${companyId ?? ""}`; + const selectedTechnician = resolveTechnician(roster, vendor); + + useEffect(() => { + if (open) setIsEditing(startInEdit); + }, [open, startInEdit, vendor]); useEffect(() => { if (!open) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); + if (event.key === "Escape" && !isEditing) onClose(); }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [onClose, open]); + }, [isEditing, onClose, open]); return ( { + if (reason === "escapeKeyDown" && isEditing) return; + onClose(); + }} anchor="right" aria-labelledby="vendor-detail-drawer-title" ModalProps={{ keepMounted: false }} @@ -304,9 +416,21 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer }} > - + - {isLoading ? ( + {isEditing && vendor ? ( + setIsEditing(false)} + onRequestDeactivation={onRequestDeactivation} + /> + ) : isLoading ? ( @@ -316,14 +440,14 @@ export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawer ) : roster ? ( <> - - navigate(editHref)} /> + + setIsEditing(true)} /> ) : ( - + No vendor selected. - + )} diff --git a/src/app/(protected)/vendors/_components/vendor-filter-options.tsx b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx new file mode 100644 index 00000000..c9be7d45 --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-filter-options.tsx @@ -0,0 +1,252 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; +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, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { + VendorFacets, + VendorFacetCompany, + VendorFacetJobBucket, + VendorFacetLocation, +} from "@/domain/vendors/types/vendor"; +import type { VendorFilters } from "./vendor-filters"; + +type DraftSetter = Dispatch>; + +function FilterSection({ + title, + icon, + children, +}: { + title: string; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + } aria-controls={`${title}-filters-content`}> + + {icon} + + {title} + + + + + {children} + + + ); +} + +function CompanyFilter({ + options, + draft, + setDraft, +}: { + options: VendorFacetCompany[]; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + const allSelected = draft.companies.length === options.length && options.length > 0; + 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={options.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 { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
    + ); +} + +function TradeFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + + setDraft((current) => ({ ...current, trades: value as string[] })) + } + renderValue={(value, getItemProps) => + value.map((option, index) => { + const { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +interface FilterProps { + options: T[]; + draft: VendorFilters; + setDraft: DraftSetter; +} + +function LocationFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.label === value.label + } + value={options.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 { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +function JobCountFilter({ options, draft, setDraft }: FilterProps) { + return ( + }> + (typeof option === "string" ? option : option.label)} + isOptionEqualToValue={(option, value) => + typeof option === "string" || typeof value === "string" + ? option === value + : option.id === value.id + } + value={options.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 { key, ...chipProps } = getItemProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + ); +} + +export function VendorFilterOptions({ + facets, + draft, + setDraft, +}: { + facets: VendorFacets | undefined; + draft: VendorFilters; + setDraft: DraftSetter; +}) { + return ( + <> + + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-filters-drawer.tsx index ae4b51ba..e3b91cf5 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, 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 { VendorFilterOptions } from "./vendor-filter-options"; import { emptyVendorFilters, type VendorFilters } from "./vendor-filters"; +import type { VendorFacets } from "@/domain/vendors/types/vendor"; 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, @@ -74,23 +25,12 @@ export function VendorFiltersDrawer({ const [draft, setDraft] = useState(appliedFilters); useEffect(() => { - if (open) { - setDraft(appliedFilters); - } + if (open) setDraft(appliedFilters); }, [open, appliedFilters]); - const companyOptions = useMemo(() => facets?.companies ?? [], [facets]); - const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]); - const locationOptions = useMemo(() => facets?.locations ?? [], [facets]); - const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]); - const activeCount = draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length; - const handleClear = () => { - setDraft(emptyVendorFilters); - }; - const handleApply = () => { onApply(draft); onClose(); @@ -133,165 +73,7 @@ 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) => ( - - )} - /> - +
    `1px solid ${theme.palette.divider}`, }} > -
    diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index 32f91675..d6d5cbf7 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -28,6 +28,7 @@ interface VendorRosterFormFieldsProps { selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; + showTechnicianStatus?: boolean; } function CompanyNameField({ @@ -54,6 +55,7 @@ function CompanyNameField({ )} @@ -119,10 +122,7 @@ function CompanyFields({ }: VendorRosterFormFieldsProps) { return ( - - Company - - + ( field.onChange(formatNorthAmericanPhone(event.target.value))} error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message ?? "Phone or email required"} + helperText={errors.companyPhone?.message} fullWidth /> )} @@ -155,7 +155,8 @@ function CompanyFields({ render={({ field }) => ( } + render={({ field }) => ( + + )} /> - + ( )} @@ -206,7 +214,14 @@ function CompanyFields({ control={control} name="notes" render={({ field }) => ( - + )} /> @@ -221,6 +236,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; + showStatus: boolean; } function TechnicianRow({ @@ -230,12 +246,13 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, + showStatus, }: TechnicianRowProps) { return ( - Technician {index + 1} + {index + 1} - TECHNICIAN @@ -256,7 +273,8 @@ function TechnicianRow({ render={({ field }) => ( ( field.onChange(formatNorthAmericanPhone(event.target.value))} @@ -292,26 +310,29 @@ function TechnicianRow({ error={Boolean(error?.email)} helperText={error?.email?.message} fullWidth + sx={{ display: "none" }} /> )} /> - ( - field.onChange(checked)} - /> - } - label={field.value ? "Active" : "Inactive"} - /> - )} - /> + {showStatus && ( + ( + field.onChange(checked)} + /> + } + label={field.value ? "Active" : "Inactive"} + /> + )} + /> + )} ); } @@ -320,10 +341,12 @@ function TechniciansFieldArray({ control, errors, tradeOptions, + showStatus, }: { control: Control; errors: FieldErrors; tradeOptions: string[]; + showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -366,6 +389,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} + showStatus={showStatus} /> )) )} @@ -375,12 +399,17 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [] } = props; + const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; return ( - + ); } diff --git a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx index e3e7cfa5..b8ad2a9c 100644 --- a/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx +++ b/src/app/(protected)/vendors/_components/vendors-list-toolbar.tsx @@ -1,13 +1,9 @@ -import FilterListIcon from "@mui/icons-material/FilterList"; -import { - Button, - Chip, - CircularProgress, - Stack, - TablePagination, - TextField, - Typography, -} from "@mui/material"; +import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material"; +import { Plus, SlidersHorizontal } from "lucide-react"; +import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { Text } from "@/components/ui/text"; +import { ToolbarButton } from "@/components/ui/toolbar-button"; interface VendorsListToolbarProps { globalCount: number; @@ -33,52 +29,46 @@ export function VendorsListToolbar({ onAddVendor, }: VendorsListToolbarProps) { return ( - - - - Vendors / Technicians - - - {globalCount} vendor entries · {activeCount} active - - {isFetching && !isLoading && } - - - - onSearchChange(e.target.value)} - placeholder="Search name, company, trade..." - className="min-w-[260px]" - /> - - - - + + } + /> ); } @@ -90,16 +80,21 @@ interface AppliedChipEntry { interface VendorAppliedFiltersBarProps { chips: AppliedChipEntry[]; + resultCount: number; onClearAll: () => void; } -export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) { +export function VendorAppliedFiltersBar({ + chips, + resultCount, + onClearAll, +}: VendorAppliedFiltersBarProps) { if (chips.length === 0) return null; return ( - - Applied: - + + {resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by + {chips.map((chip) => ( - + {totalCount} {isActive ? "active" : "inactive"}{" "} - {totalCount === 1 ? "vendor entry" : "vendor entries"} + {totalCount === 1 ? "technician" : "technicians"} {isFiltered && " · filtered"} - {` · ${activeCount} active · ${inactiveCount} inactive`} - - onPageChange(nextPage)} - rowsPerPage={pageSize} - onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} - rowsPerPageOptions={[12, 24, 48]} - /> + + + {activeCount} active · {inactiveCount} inactive + + {totalCount > pageSize && ( + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[24, 48, 96]} + /> + )} ); } diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index 802fff8a..db588cff 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -1,11 +1,10 @@ import { type KeyboardEvent, type MouseEvent } from "react"; -import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Avatar, - Chip, + Box, CircularProgress, IconButton, Link, @@ -18,8 +17,8 @@ import { TableHead, TableRow, Tooltip, - Typography, } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorListItem } from "@/domain/vendors/types/vendor"; const TABLE_COLUMNS = [ @@ -30,9 +29,6 @@ const TABLE_COLUMNS = [ "Phone", "Email", "Total Jobs", - "Status", - "View", - "Edit", ] as const; function getInitials(name: string): string { @@ -54,19 +50,34 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } +function VendorStatus({ isActive }: { isActive: boolean }) { + return ( + + + {isActive ? "Active" : "Inactive"} + + ); +} + interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } -function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) { - const navigate = useNavigate(); +function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) { const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—"; - const hasVendorId = row.id != null; const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -88,37 +99,62 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo onKeyDown={handleKeyDown} > - {row.companyName || "—"} + + {row.companyName || "—"} + - + {getInitials(row.contactName)} - {row.contactName || "—"} + + {row.contactName || "—"} + - {locationText} - + {locationText} + {primaryTrade === "—" ? ( "—" ) : ( {primaryTrade} - {extraTrades > 0 && } + {extraTrades > 0 && ( + + +{extraTrades} + + )} )} - + {row.phone ? ( - + {row.phone} ) : ( @@ -127,71 +163,53 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo {row.email ? ( - + {row.email} ) : ( "—" )} - {row.totalJobs ?? 0} + {row.totalJobs ?? 0} - + - - - { - event.stopPropagation(); - onOpenDetail(row); - }} - > - - - - - + + + { + event.stopPropagation(); + onOpenDetail(row); + }} + > + + + { event.stopPropagation(); - navigate( - row.id != null - ? `/vendors/edit/${row.id}` - : `/vendors/company/${row.companyId ?? ""}`, - ); + onOpenEdit(row); }} > - {hasVendorId && row.isActive && ( - - { - event.stopPropagation(); - onOpenDeactivation(row); - }} - > - - - - )} @@ -200,50 +218,118 @@ function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRo interface VendorsTableProps { rows: VendorListItem[]; + isActive: boolean; + isFiltered: boolean; isLoading: boolean; showError: boolean; onOpenDetail: (row: VendorListItem) => void; - onOpenDeactivation: (row: VendorListItem) => void; + onOpenEdit: (row: VendorListItem) => void; } export function VendorsTable({ rows, + isActive, + isFiltered, isLoading, showError, onOpenDetail, - onOpenDeactivation, + onOpenEdit, }: VendorsTableProps) { const showEmptyRow = !isLoading && !showError && rows.length === 0; + const columnCount = TABLE_COLUMNS.length + 2; return ( - - + +
    + + + + + + + + + + + {TABLE_COLUMNS.map((column) => ( - + {column} ))} + Status + {isLoading ? ( - + ) : showEmptyRow ? ( - - - No vendors match the current filters. - + + + + + {`No ${isActive ? "active" : "inactive"} technicians found`} + + + {isFiltered + ? "Try a different search, or add a new vendor." + : "Add a vendor to get started."} + + ) : ( @@ -252,7 +338,7 @@ export function VendorsTable({ key={String(row.id ?? `company-${row.companyId ?? ""}`)} row={row} onOpenDetail={onOpenDetail} - onOpenDeactivation={onOpenDeactivation} + onOpenEdit={onOpenEdit} /> )) )} diff --git a/src/app/(protected)/vendors/index.tsx b/src/app/(protected)/vendors/index.tsx index a084b59b..48447ad6 100644 --- a/src/app/(protected)/vendors/index.tsx +++ b/src/app/(protected)/vendors/index.tsx @@ -23,16 +23,91 @@ interface AppliedChip { label: string; } +function statusTabLabel(label: string, count: number) { + return ( + + {label} + + {count} + + + ); +} + +function VendorStatusTabs({ + isActive, + activeCount, + inactiveCount, + onChange, +}: { + isActive: boolean; + activeCount: number; + inactiveCount: number; + onChange: (isActive: boolean) => void; +}) { + return ( + onChange(value === "active")} + aria-label="Vendor status" + sx={{ + minHeight: 36, + flexShrink: 0, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + backgroundColor: "var(--color-bg-surface)", + mx: -2, + px: 1.5, + "& .MuiTab-root": { + minHeight: 36, + px: 2, + py: 0, + fontSize: 13, + fontWeight: 500, + textTransform: "none", + }, + "& .MuiTab-root:first-of-type": { width: 102.84375 }, + "& .Mui-selected": { fontWeight: 600 }, + "& .MuiTabs-indicator": { height: 2 }, + }} + > + + + + ); +} + function describeChips( filters: VendorFilters, jobBucketLabels: Map, ): AppliedChip[] { const chips: AppliedChip[] = []; - filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value })); - filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value })); - filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value })); + filters.companies.forEach((value) => + chips.push({ category: "companies", value, label: `Company: ${value}` }), + ); + filters.trades.forEach((value) => + chips.push({ category: "trades", value, label: `Trade: ${value}` }), + ); + filters.locations.forEach((value) => + chips.push({ category: "locations", value, label: `Location: ${value}` }), + ); filters.jobBuckets.forEach((value) => - chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }), + chips.push({ + category: "jobBuckets", + value, + label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`, + }), ); return chips; } @@ -42,7 +117,12 @@ export default function VendorsListPage() { const [filtersOpen, setFiltersOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [detailTarget, setDetailTarget] = useState(null); - const deactivation = useVendorDeactivation(); + const [detailStartsInEdit, setDetailStartsInEdit] = useState(false); + const closeDetail = () => { + setDetailTarget(null); + setDetailStartsInEdit(false); + }; + const deactivation = useVendorDeactivation(closeDetail); const { data: facets } = useVendorFacets(filters.isActive); const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams); @@ -61,7 +141,7 @@ export default function VendorsListPage() { const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels); return ( - + setCreateOpen(true)} /> - - filters.setIsActiveWithReset(value === "active") - } - aria-label="Vendor status" - > - - - + ({ key: `${chip.category}-${chip.value}`, label: chip.label, @@ -102,10 +179,18 @@ export default function VendorsListPage() { 0 || Boolean(filters.debouncedSearch)} isLoading={isLoading} showError={Boolean(error)} - onOpenDetail={setDetailTarget} - onOpenDeactivation={deactivation.open} + onOpenDetail={(row) => { + setDetailStartsInEdit(false); + setDetailTarget(row); + }} + onOpenEdit={(row) => { + setDetailStartsInEdit(true); + setDetailTarget(row); + }} /> setDetailTarget(null)} + startInEdit={detailStartsInEdit} + onRequestDeactivation={deactivation.open} + onClose={closeDetail} /> void refetch() }); - - const [dispatchOpen, setDispatchOpen] = useState(false); - const [dispatchDetailId, setDispatchDetailId] = useState(null); - - if (isLoading) { + if (view.isLoading) { return ( @@ -41,11 +23,11 @@ export default function WorkOrderViewPage() { ); } - if (error || !wo) { + if (view.error || !view.wo) { return ( - {error instanceof Error ? error.message : "Work order not found."} + {view.error instanceof Error ? view.error.message : "Work order not found."} ); @@ -53,56 +35,67 @@ export default function WorkOrderViewPage() { return ( - statusMutation.mutate(status)} - onDispatch={() => setDispatchOpen(true)} - onEdit={() => navigate(`/workorders/edit/${id}`)} - onBack={() => navigate("/workorders")} - /> - assignmentMutation.mutate(payload)} - /> - - - void vendorEditor.open(event, vendorId, vendorName) - } - onViewDispatch={setDispatchDetailId} - /> - view.statusMutation.mutate(status)} + onOpenDispatch={() => view.setDispatchOpen(true)} /> - {dispatchOpen && ( + + view.assignmentMutation.mutate({ userId, userName })} + onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })} + /> + + + + + + + + {view.dispatchOpen && ( setDispatchOpen(false)} - onDispatched={() => void refetch()} + workOrder={view.wo} + onClose={() => view.setDispatchOpen(false)} + onDispatched={() => void view.refetch()} /> )} - {dispatchDetailId != null && ( + {view.dispatchDetailId != null && ( setDispatchDetailId(null)} - onUpdated={() => void refetch()} + onClose={() => view.setDispatchDetailId(null)} + onUpdated={() => void view.refetch()} /> )} - { + if (!view.vendorMutation.isPending) view.setVendorEditor(null); + }} + onSave={view.saveVendorEditor} + onEditorChange={view.setVendorEditor} /> ); diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx new file mode 100644 index 00000000..5d3804bb --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-co-line.tsx @@ -0,0 +1,19 @@ +import type { ElementType } from "react"; + +type CompDocCoLineProps = { + icon: ElementType; + text: string; + bold?: boolean; +}; + +export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) { + return ( +
    + {text} + +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx new file mode 100644 index 00000000..c5f08558 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-detail-row.tsx @@ -0,0 +1,39 @@ +import type { ElementType } from "react"; + +type CompDocDetailRowProps = { + icon: ElementType; + label: string; + value: string; + last?: boolean; +}; + +export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) { + return ( +
    + +
    +
    {label}
    +
    + {value} +
    +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx new file mode 100644 index 00000000..3a38f2e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx @@ -0,0 +1,75 @@ +import { Building2, MapPin, Users, Wrench } from "lucide-react"; +import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { CompDocDetailRow } from "./comp-doc-detail-row"; +import { CompDocEditableRow } from "./comp-doc-editable-row"; + +type CompDocDetailsSectionProps = { + row: WorkOrderTableRow; + site: CompDocSiteInfo; + editAddress: string; + onAddressChange: (value: string) => void; + editDateCompleted: string; + onDateCompletedChange: (value: string) => void; + amazonRep: string; + seaRep: string; + isGenerated: boolean; +}; + +export function CompDocDetailsSection({ + row, + site, + editAddress, + onAddressChange, + editDateCompleted, + onDateCompletedChange, + amazonRep, + seaRep, + isGenerated, +}: CompDocDetailsSectionProps) { + return ( +
    +
    + Work Order Details +
    +
    + + + + + + +
    +
    + ); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx new file mode 100644 index 00000000..fad1529f --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer-draft.tsx @@ -0,0 +1,50 @@ +import { Ban, Save, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-btn-sx"; + +type CompDocDialogFooterDraftProps = { + onMarkNotRequired: () => void; + onSaveDraft: () => void; + onGenerate: () => void; +}; + +export function CompDocDialogFooterDraft({ + onMarkNotRequired, + onSaveDraft, + onGenerate, +}: CompDocDialogFooterDraftProps) { + return ( + <> + +
    + + +
    + + ); +} 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-footer.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx new file mode 100644 index 00000000..4e35987c --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx @@ -0,0 +1,47 @@ +import type { DocState } from "@/domain/work-orders/types/completion-doc"; +import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft"; +import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated"; +import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required"; + +type CompDocDialogFooterProps = { + docState: DocState; + generatedAt: string; + onMarkNotRequired: () => void; + onUndo: () => void; + onSaveDraft: () => void; + onGenerate: () => void; + onDownloadPdf: () => void; +}; + +export function CompDocDialogFooter({ + docState, + generatedAt, + onMarkNotRequired, + onUndo, + onSaveDraft, + onGenerate, + onDownloadPdf, +}: CompDocDialogFooterProps) { + return ( +
    + {docState === "draft" && ( + + )} + {docState === "generated" && ( + + )} + {docState === "not-required" && } +
    + ); +} 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 new file mode 100644 index 00000000..d014c7e4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-header.tsx @@ -0,0 +1,94 @@ +import { FileText, X } from "lucide-react"; +import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title"; + +type CompDocDialogHeaderProps = { + row: WorkOrderTableRow; + cfg: CompletionDocStateConfig; + onClose: () => void; +}; + +export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) { + return ( +
    +
    + +
    +
    + Completion Document +
    +
    + WO{" "} + + #{row.woNumber} + + {" · "} + {serviceTitle(row)} +
    +
    +
    +
    + + + {cfg.pillLabel} + + +
    +
    + ); +} 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 new file mode 100644 index 00000000..ce2fbdf9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -0,0 +1,128 @@ +import type { MouseEvent } from "react"; +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +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 { 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 { CompDocPreview } from "./comp-doc-preview"; +import { CompDocSidebar } from "./comp-doc-sidebar"; + +type CompDocDialogProps = { + row: WorkOrderTableRow; + open: boolean; + onOpenChange: (open: boolean) => void; + data?: CompDocData; + onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + onDateCompletedChange?: (date: string) => void | Promise; +}; + +export function CompDocDialog({ + row, + open, + onOpenChange, + data, + onSave, + onDateCompletedChange, +}: CompDocDialogProps) { + const { + site, + notes, + setNotes, + docState, + generatedAt, + editAddress, + setEditAddress, + editDateCompleted, + setEditDateCompleted, + amazonRep, + seaRep, + cfg, + isNotRequired, + isGenerated, + handleMarkNotRequired, + handleUndo, + handleSaveDraft, + handleGenerate, + handleDownloadPdf, + } = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange }); + + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0", + style: { + borderRadius: 12, + width: "min(1024px, calc(100vw - 32px))", + maxWidth: "none", + }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + + Completion Document — WO #{row.woNumber} + + Generate, save or mark not-required the completion document for this work order. + + +
    + onOpenChange(false)} /> + +
    + + +
    + + +
    +
    +
    + ); +} 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 ( +
    + +