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.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)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index cfdcf7d7..ea7ebf00 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,39 +1,21 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { Alert, Box, CircularProgress } from "@mui/material"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; -import { useVendorEditor } from "./_components/use-vendor-editor"; -import { WorkOrderActivity } from "./_components/work-order-activity"; -import { - DispatchAccordion, - WorkOrderDetailsSection, - WorkOrderHeader, - WorkOrderSummaryAccordions, -} from "./_components/work-order-view-sections"; -import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { - useChangeWorkOrderAssignment, - useChangeWorkOrderStatus, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel"; +import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion"; +import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog"; +import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions"; +import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; +import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; +import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; export default function WorkOrderViewPage() { const { id } = useParams(); - const navigate = useNavigate(); const workOrderId = id ?? ""; + const view = useWorkOrderViewPage(workOrderId); - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); - const { data: users = [] } = useWorkOrderUsers(); - const statusMutation = useChangeWorkOrderStatus(workOrderId); - const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); - const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => 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 ( +
+ +