diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 526045d..0000000 --- a/.mergify.yml +++ /dev/null @@ -1 +0,0 @@ -extends: .github diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml index 871a222..7b3f1bf 100644 --- a/.redocly.lint-ignore.yaml +++ b/.redocly.lint-ignore.yaml @@ -1,5 +1,16 @@ # This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. # See https://redocly.com/docs/cli/ for more information. -# -# Intentionally empty for AP-14 foundation. Add justified ignores in the same -# style as Sea-Haven-Industries/procurement-ingest when needed. +# Login and callback fail with a 302 redirect, or 500 when Cognito is not configured. +packages/api/openapi/paths/auth-login.yaml: + operation-4xx-response: + - '#/get/responses' +packages/api/openapi/paths/auth-callback.yaml: + operation-4xx-response: + - '#/get/responses' +# Health returns 200. Ready returns 200 or 503. Neither returns 4xx. +packages/api/openapi/paths/health.yaml: + operation-4xx-response: + - '#/get/responses' +packages/api/openapi/paths/ready.yaml: + operation-4xx-response: + - '#/get/responses' diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts index 160476a..7089c70 100644 --- a/e2e/dashboard-grids.spec.ts +++ b/e2e/dashboard-grids.spec.ts @@ -53,6 +53,23 @@ test.describe("dashboard grids", () => { /Pay run stub: [2-9]\d* invoice\(s\) selected/, ); + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + await drawer.getByTestId("columns-hidden-search").fill("Payment Date"); + await drawer + .getByTestId("columns-hidden-paymentDate") + .locator('input[type="checkbox"]') + .evaluate((el: HTMLInputElement) => { + el.click(); + }); + await expect( + readyGrid.locator('.MuiDataGrid-columnHeader[data-field="payDate"]'), + ).toBeVisible(); + await page.getByRole("button", { name: "Close columns" }).evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(drawer).toBeHidden(); + const payDateCell = readyGrid.locator('[data-field="payDate"]').nth(1); await payDateCell.dblclick(); const editor = readyGrid @@ -61,12 +78,12 @@ test.describe("dashboard grids", () => { await editor.fill("2026-09-01"); await editor.press("Enter"); - await expect(payDateCell).toContainText("Sep 1, 2026"); + await expect(payDateCell).toContainText("09/01/2026"); await page.getByTestId("tab-pending").click(); await page.getByTestId("tab-ready").click(); await expect( page.getByTestId("grid-ready").locator('[data-field="payDate"]').nth(1), - ).toContainText("Sep 1, 2026"); + ).toContainText("09/01/2026"); }); }); diff --git a/e2e/invoice-columns.spec.ts b/e2e/invoice-columns.spec.ts new file mode 100644 index 0000000..e2853cb --- /dev/null +++ b/e2e/invoice-columns.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing columns drawer (AP-45)", () => { + test("Columns drawer can hide Tagging and Reset restores it", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'), + ).toBeVisible(); + + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + await expect(drawer).toBeVisible(); + + const taggingRow = drawer.getByTestId("columns-visible-tagging"); + await taggingRow.scrollIntoViewIfNeeded(); + await taggingRow.locator('input[type="checkbox"]').evaluate((el: HTMLInputElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'), + ).toHaveCount(0); + + await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'), + ).toBeVisible(); + }); + + test("locked Vendor column stays visible and Reset clears the hidden search", async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + const vendorBox = drawer + .getByTestId("columns-visible-vendor") + .locator('input[type="checkbox"]'); + await expect(vendorBox).toBeDisabled(); + await vendorBox.evaluate((el: HTMLInputElement) => { + el.disabled = false; + el.click(); + }); + await expect( + page + .getByTestId("grid-pending") + .locator('.MuiDataGrid-columnHeader[data-field="vendorName"]'), + ).toBeVisible(); + await page.getByTestId("tab-ready").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-ready").locator('.MuiDataGrid-columnHeader[data-field="vendorName"]'), + ).toBeVisible(); + + const search = drawer.getByTestId("columns-hidden-search"); + await search.fill("no-such-column"); + await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(search).toHaveValue(""); + }); +}); diff --git a/e2e/invoice-filter-drawer.spec.ts b/e2e/invoice-filter-drawer.spec.ts new file mode 100644 index 0000000..4fa4eab --- /dev/null +++ b/e2e/invoice-filter-drawer.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing filter drawer (AP-46)", () => { + test("Filter drawer clears applied filters", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("vendor-filter").fill("Acme"); + await page.getByTestId("filter-toggle").click(); + const drawer = page.getByTestId("filter-drawer"); + await expect(drawer).toBeVisible(); + await expect(page.getByTestId("filter-drawer-vendor")).toContainText("Acme"); + + await page.getByTestId("filter-drawer-clear").click(); + await expect(page.getByTestId("vendor-filter")).toHaveValue(""); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toBeHidden(); + }); + + test("closing the filter drawer leaves applied filters in place", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("vendor-filter").fill("Acme"); + await page.getByTestId("filter-toggle").click(); + await expect(page.getByTestId("filter-drawer")).toBeVisible(); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + + await page.getByRole("button", { name: "Close filters" }).evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(page.getByTestId("filter-drawer")).toBeHidden(); + await expect(page.getByTestId("vendor-filter")).toHaveValue("Acme"); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + }); +}); diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts new file mode 100644 index 0000000..2f340d3 --- /dev/null +++ b/e2e/invoice-filters.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing filters (AP-43)", () => { + test("Action and Vendor filters reduce grid rows and badge counts", async ({ page }) => { + await page.goto("/"); + const grid = page.getByTestId("grid-pending"); + await expect(grid.locator(".MuiDataGrid-row").first()).toBeVisible(); + const before = await grid.locator(".MuiDataGrid-row").count(); + expect(before).toBeGreaterThan(0); + + await expect(page.getByTestId("action-filter").getByPlaceholder("Search")).toBeVisible(); + await page.getByTestId("action-filter").click(); + await page.getByRole("option", { name: "Approve invoice" }).click(); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + + await page.getByTestId("vendor-filter").fill("Harbor"); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); + await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); + await expect(page.getByTestId("tab-pending").locator("span").last()).toHaveText("1"); + await expect(page.getByTestId("attention-banner")).toContainText( + "1 invoices require approval. Show invoices ready to approve.", + ); + }); +}); diff --git a/package-lock.json b/package-lock.json index 1bc709e..4e3c4d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "packages/*" ], "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/ibm-plex-sans": "^5.3.0", @@ -47,7 +50,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" @@ -914,6 +917,112 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -2258,9 +2367,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3249,13 +3358,6 @@ "solid-js": "^1.6.12" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -3815,34 +3917,17 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3860,81 +3945,26 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -5041,9 +5071,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -6203,16 +6233,6 @@ "lz-string": "bin/bin.js" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -6460,13 +6480,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", @@ -7246,11 +7259,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.0", @@ -7307,16 +7323,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", @@ -7621,38 +7627,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -7660,16 +7659,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -7710,6 +7709,16 @@ } } }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", @@ -7904,7 +7913,7 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.23.13", "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" @@ -7915,7 +7924,7 @@ "version": "0.1.0", "devDependencies": { "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" diff --git a/package.json b/package.json index 440cfe0..b8ed615 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ }, "packageManager": "npm@12.0.2", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@fontsource/ibm-plex-sans": "^5.3.0", @@ -70,6 +73,6 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", - "vitest": "^4.1.11" + "vitest": "^5.0.1" } } diff --git a/packages/api/openapi/paths/auth-callback.yaml b/packages/api/openapi/paths/auth-callback.yaml index b86106f..b8566a8 100644 --- a/packages/api/openapi/paths/auth-callback.yaml +++ b/packages/api/openapi/paths/auth-callback.yaml @@ -30,14 +30,3 @@ get: responses: "302": description: Redirect to the SPA or the login error page. - "400": - description: Bad request. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: - code: VALIDATION_ERROR - message: Bad request. - correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-login.yaml b/packages/api/openapi/paths/auth-login.yaml index c782154..84f7c5f 100644 --- a/packages/api/openapi/paths/auth-login.yaml +++ b/packages/api/openapi/paths/auth-login.yaml @@ -17,17 +17,6 @@ get: responses: "302": description: Redirect to Cognito hosted UI. - "400": - description: Bad request. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: - code: VALIDATION_ERROR - message: Bad request. - correlationId: 11111111-1111-4111-8111-111111111111 "500": description: Cognito is not configured. content: diff --git a/packages/api/openapi/paths/auth-logout.yaml b/packages/api/openapi/paths/auth-logout.yaml index ce5162b..71b0b42 100644 --- a/packages/api/openapi/paths/auth-logout.yaml +++ b/packages/api/openapi/paths/auth-logout.yaml @@ -19,14 +19,3 @@ post: code: FORBIDDEN message: Origin is not allowed. correlationId: 11111111-1111-4111-8111-111111111111 - "400": - description: Bad request. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: - code: VALIDATION_ERROR - message: Bad request. - correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/health.yaml b/packages/api/openapi/paths/health.yaml index 818e55f..5d08746 100644 --- a/packages/api/openapi/paths/health.yaml +++ b/packages/api/openapi/paths/health.yaml @@ -15,14 +15,3 @@ get: example: stage: local sha: deadbeef - "400": - description: Bad request. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: - code: VALIDATION_ERROR - message: Bad request. - correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/ready.yaml b/packages/api/openapi/paths/ready.yaml index 7ba6d77..b64c207 100644 --- a/packages/api/openapi/paths/ready.yaml +++ b/packages/api/openapi/paths/ready.yaml @@ -15,17 +15,6 @@ get: example: status: ok database: up - "400": - description: Bad request. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: - code: VALIDATION_ERROR - message: Bad request. - correlationId: 11111111-1111-4111-8111-111111111111 "503": description: Database ping failed. content: diff --git a/packages/api/package.json b/packages/api/package.json index 1a19141..9f7b478 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -47,7 +47,7 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.23.13", "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" diff --git a/packages/shared/package.json b/packages/shared/package.json index 14f1654..a0eff5c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "typescript": "^5.9.3", - "vitest": "^4.1.11" + "vitest": "^5.0.1" }, "engines": { "node": ">=24.21.0" diff --git a/src/dashboard/ColumnsDrawer.tsx b/src/dashboard/ColumnsDrawer.tsx new file mode 100644 index 0000000..36b6d46 --- /dev/null +++ b/src/dashboard/ColumnsDrawer.tsx @@ -0,0 +1,207 @@ +import { + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import CloseIcon from "@mui/icons-material/Close"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + Box, + Button, + Checkbox, + Drawer, + FormControlLabel, + IconButton, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { CSSProperties, ReactElement } from "react"; +import { useMemo, useState } from "react"; +import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; +import { + catalogLabel, + hiddenColumnKeys, + isColumnLocked, + type ColumnPrefs, +} from "@/dashboard/column-prefs"; + +type ColumnsDrawerProps = { + open: boolean; + onClose: () => void; + prefs: ColumnPrefs; + onPrefsChange: (next: ColumnPrefs) => void; + onReset: () => void; +}; + +function SortableVisibleRow({ + columnKey, + onToggle, +}: { + columnKey: InvoiceColumnKey; + onToggle: (key: InvoiceColumnKey, checked: boolean) => void; +}): ReactElement { + const locked = isColumnLocked(columnKey); + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: columnKey, + }); + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + display: "flex", + alignItems: "center", + gap: 8, + }; + + return ( + + + + + onToggle(columnKey, event.target.checked)} + /> + } + label={catalogLabel(columnKey)} + /> + + ); +} + +export function ColumnsDrawer({ + open, + onClose, + prefs, + onPrefsChange, + onReset, +}: ColumnsDrawerProps): ReactElement { + const [hiddenQuery, setHiddenQuery] = useState(""); + const [viewAllHidden, setViewAllHidden] = useState(false); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + + const hidden = useMemo(() => hiddenColumnKeys(prefs.visibleOrder), [prefs.visibleOrder]); + const filteredHidden = useMemo(() => { + const q = hiddenQuery.trim().toLowerCase(); + const list = q ? hidden.filter((key) => catalogLabel(key).toLowerCase().includes(q)) : hidden; + if (viewAllHidden || q) { + return list; + } + return list.slice(0, 5); + }, [hidden, hiddenQuery, viewAllHidden]); + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const oldIndex = prefs.visibleOrder.indexOf(active.id as InvoiceColumnKey); + const newIndex = prefs.visibleOrder.indexOf(over.id as InvoiceColumnKey); + if (oldIndex < 0 || newIndex < 0) { + return; + } + onPrefsChange({ visibleOrder: arrayMove(prefs.visibleOrder, oldIndex, newIndex) }); + }; + + const onToggle = (key: InvoiceColumnKey, checked: boolean) => { + if (isColumnLocked(key)) { + return; + } + if (checked) { + if (prefs.visibleOrder.includes(key)) { + return; + } + onPrefsChange({ visibleOrder: [...prefs.visibleOrder, key] }); + return; + } + onPrefsChange({ visibleOrder: prefs.visibleOrder.filter((item) => item !== key) }); + }; + + return ( + + + + Columns + + + + + + + + + + + Visible + + + + + {prefs.visibleOrder.map((key) => ( + + ))} + + + + + + Hidden + + setHiddenQuery(event.target.value)} + sx={{ mb: 1 }} + slotProps={{ htmlInput: { "data-testid": "columns-hidden-search" } }} + /> + + {filteredHidden.map((key) => ( + onToggle(key, event.target.checked)} /> + } + label={catalogLabel(key)} + /> + ))} + + {!viewAllHidden && !hiddenQuery && hidden.length > 5 ? ( + + ) : null} + + ); +} diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index 4b98833..7c93f7d 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,23 +1,20 @@ import { useMemo, useState } from "react"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Stack, - Tab, - Tabs, - Typography, -} from "@mui/material"; +import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; +import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer"; +import { FilterDrawer } from "@/dashboard/FilterDrawer"; +import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; +import { columnsForApprovedGrid, columnsFromCatalogKeys } from "@/dashboard/columns"; import { - allInvoicesColumns, - invoiceAttentionColumns, - readyToPayColumns, -} from "@/dashboard/columns"; + loadColumnPrefs, + resetColumnPrefs, + saveColumnPrefs, + type ColumnPrefs, +} from "@/dashboard/column-prefs"; +import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; +import { InvoiceTabLabel } from "@/dashboard/invoice-tabs"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; @@ -30,6 +27,29 @@ const emptySelection: GridRowSelectionModel = { ids: new Set(), }; +const emptyFilters = EMPTY_INVOICE_FILTERS; + +const invoiceTabsSx = { + minHeight: 44, + "& .MuiTab-root": { + bgcolor: "rgba(47,128,237,0.12)", + mr: 0.5, + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + color: "text.primary", + textTransform: "none", + }, + "& .Mui-selected": { + bgcolor: "#fff !important", + color: "text.primary !important", + }, + "& .MuiTabs-indicator": { + height: 3, + borderRadius: 1, + backgroundColor: "#7B61FF", + }, +} as const; + const rowClickSx = { border: 1, borderColor: "divider", @@ -38,13 +58,39 @@ const rowClickSx = { borderRadius: 1, } as const; +function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] { + const vendorQuery = filters.vendorQuery.trim().toLowerCase(); + return rows.filter((invoice) => { + if (filters.action && invoice.actionLabel !== filters.action) { + return false; + } + if (vendorQuery && !invoice.vendorName.toLowerCase().includes(vendorQuery)) { + return false; + } + return true; + }); +} + export function DashboardGrids() { const navigate = useNavigate(); const [tab, setTab] = useState("attention"); const [selection, setSelection] = useState(emptySelection); + const [filters, setFilters] = useState(emptyFilters); + const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false); + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); + const [columnPrefs, setColumnPrefs] = useState(() => loadColumnPrefs()); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); + const catalogColumns = useMemo( + () => columnsFromCatalogKeys(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); + const approvedColumns = useMemo( + () => columnsForApprovedGrid(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); + const openInvoice = (params: GridRowParams) => { navigate("/invoices/:id", { params: { id: params.row.id } }); }; @@ -69,6 +115,21 @@ export function DashboardGrids() { ); }, [invoices]); + const filteredAttention = useMemo( + () => applyInvoiceFilters(attentionRows, filters), + [attentionRows, filters], + ); + const filteredAll = useMemo(() => applyInvoiceFilters(invoices, filters), [invoices, filters]); + const filteredApproved = useMemo( + () => applyInvoiceFilters(approvedRows, filters), + [approvedRows, filters], + ); + const filteredRejected = useMemo( + () => applyInvoiceFilters(rejectedRows, filters), + [rejectedRows, filters], + ); + const filteredLate = useMemo(() => applyInvoiceFilters(lateRows, filters), [lateRows, filters]); + const selectedCount = countSelectedRows( selection, approvedRows.map((invoice) => invoice.id), @@ -82,6 +143,11 @@ export function DashboardGrids() { return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate }); }; + const updateColumnPrefs = (next: ColumnPrefs) => { + setColumnPrefs(next); + saveColumnPrefs(next); + }; + if (isLoading) { return ( @@ -109,71 +175,96 @@ export function DashboardGrids() { }} variant="scrollable" aria-label="Invoice Processing tabs" - sx={{ - minHeight: 44, - "& .MuiTab-root": { - bgcolor: "rgba(47,128,237,0.12)", - mr: 0.5, - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - color: "text.primary", - }, - "& .Mui-selected": { - bgcolor: "#fff !important", - color: "text.primary !important", - }, - "& .MuiTabs-indicator": { display: "none" }, - }} + sx={invoiceTabsSx} > + } value="attention" data-testid="tab-pending" /> - + } + value="all" + data-testid="tab-all" + /> + + } value="approved" data-testid="tab-ready" /> + } value="rejected" data-testid="tab-rejected" /> - + + } + value="late" + data-testid="tab-late" + /> - {attentionRows.length > 0 && tab === "attention" ? ( - - {attentionRows.length} invoices require approval. Show Invoices ready to approve. - + {filteredAttention.length > 0 && tab === "attention" ? ( + + {filteredAttention.length} invoices require approval.{" "} + + Show invoices ready to approve. + + ) : null} - - - - - - {tab === "approved" ? : null} - + setFilterDrawerOpen(true)} + onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)} + trailingActions={ + tab === "approved" ? : null + } + /> {tab === "attention" && ( @@ -182,8 +273,8 @@ export function DashboardGrids() { {tab === "all" && ( @@ -192,8 +283,8 @@ export function DashboardGrids() { {tab === "approved" && ( setSelection(model)} @@ -208,8 +299,8 @@ export function DashboardGrids() { {tab === "rejected" && ( @@ -218,8 +309,8 @@ export function DashboardGrids() { {tab === "late" && ( @@ -229,6 +320,23 @@ export function DashboardGrids() { Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices + + setColumnsDrawerOpen(false)} + prefs={columnPrefs} + onPrefsChange={updateColumnPrefs} + onReset={() => { + const next = resetColumnPrefs(); + setColumnPrefs(next); + }} + /> + setFilterDrawerOpen(false)} + filters={filters} + onFiltersChange={setFilters} + /> ); } diff --git a/src/dashboard/FilterDrawer.tsx b/src/dashboard/FilterDrawer.tsx new file mode 100644 index 0000000..470a880 --- /dev/null +++ b/src/dashboard/FilterDrawer.tsx @@ -0,0 +1,71 @@ +import CloseIcon from "@mui/icons-material/Close"; +import { Box, Button, Drawer, IconButton, Stack, Typography } from "@mui/material"; +import type { ReactElement } from "react"; +import { countActiveFilters, type InvoiceFilterState } from "@/dashboard/invoice-filters"; + +type FilterDrawerProps = { + open: boolean; + onClose: () => void; + filters: InvoiceFilterState; + onFiltersChange: (next: InvoiceFilterState) => void; +}; + +export function FilterDrawer({ + open, + onClose, + filters, + onFiltersChange, +}: FilterDrawerProps): ReactElement { + const activeCount = countActiveFilters(filters); + + return ( + + + Filters + + + + + + + {activeCount === 0 + ? "No filters applied." + : `${activeCount} active filter${activeCount === 1 ? "" : "s"}.`} + + + + + + Action + + + {filters.action ?? "—"} + + + + + Vendor + + + {filters.vendorQuery.trim() || "—"} + + + + + + + ); +} diff --git a/src/dashboard/InvoiceFilterToolbar.tsx b/src/dashboard/InvoiceFilterToolbar.tsx new file mode 100644 index 0000000..9fe42f0 --- /dev/null +++ b/src/dashboard/InvoiceFilterToolbar.tsx @@ -0,0 +1,117 @@ +import FilterListIcon from "@mui/icons-material/FilterList"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import ViewWeekOutlinedIcon from "@mui/icons-material/ViewWeekOutlined"; +import { + Autocomplete, + Badge, + Box, + Button, + IconButton, + InputAdornment, + Stack, + TextField, +} from "@mui/material"; +import type { ReactElement } from "react"; +import { INVOICE_ACTION_OPTIONS } from "@/dashboard/column-catalog"; +import { countActiveFilters, type InvoiceFilterState } from "@/dashboard/invoice-filters"; + +type InvoiceFilterToolbarProps = { + filters: InvoiceFilterState; + onFiltersChange: (next: InvoiceFilterState) => void; + onOpenFilterDrawer: () => void; + onOpenColumnsDrawer: () => void; + trailingActions?: ReactElement | null; +}; + +const fieldSx = { + minWidth: 180, + "& .MuiOutlinedInput-root": { + borderRadius: "6px", + backgroundColor: "background.paper", + }, +} as const; + +export function InvoiceFilterToolbar({ + filters, + onFiltersChange, + onOpenFilterDrawer, + onOpenColumnsDrawer, + trailingActions = null, +}: InvoiceFilterToolbarProps): ReactElement { + const activeCount = countActiveFilters(filters); + + return ( + + + + onFiltersChange({ ...filters, action: value })} + renderInput={(params) => } + /> + + onFiltersChange({ ...filters, vendorQuery: event.target.value })} + sx={fieldSx} + slotProps={{ + htmlInput: { "data-testid": "vendor-filter", "aria-label": "Vendor" }, + input: { + endAdornment: filters.vendorQuery ? ( + + + + ) : undefined, + }, + }} + /> + + + + + + + + + + + + + + + + + {trailingActions} + + + ); +} diff --git a/src/dashboard/approved-columns.ts b/src/dashboard/approved-columns.ts new file mode 100644 index 0000000..b94e671 --- /dev/null +++ b/src/dashboard/approved-columns.ts @@ -0,0 +1,9 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import type { MockInvoice } from "@/mocks"; + +/** Mark a visible pay-date column editable. A hidden Payment Date stays omitted. */ +export function withEditablePayDate(columns: GridColDef[]): GridColDef[] { + return columns.map((column) => + column.field === "payDate" ? { ...column, editable: true } : column, + ); +} diff --git a/src/dashboard/column-catalog.test.ts b/src/dashboard/column-catalog.test.ts new file mode 100644 index 0000000..19c69df --- /dev/null +++ b/src/dashboard/column-catalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_VISIBLE_COLUMN_KEYS, + INVOICE_ACTION_OPTIONS, + INVOICE_COLUMN_CATALOG, + LOCKED_COLUMN_KEYS, +} from "@/dashboard/column-catalog"; + +describe("INVOICE_COLUMN_CATALOG", () => { + it("locks Stampli Visible defaults and locked columns", () => { + expect(DEFAULT_VISIBLE_COLUMN_KEYS).toEqual([ + "bolt", + "bell", + "invoiceId", + "userStatus", + "vendor", + "lastUpdate", + "invoiceDate", + "dueDate", + "amount", + "tagging", + "fs", + "eye", + ]); + expect(LOCKED_COLUMN_KEYS).toEqual(["bolt", "bell", "invoiceId", "vendor"]); + }); + + it("includes Hidden catalog entries", () => { + const hidden = INVOICE_COLUMN_CATALOG.filter((entry) => !entry.defaultVisible).map( + (entry) => entry.key, + ); + expect(hidden).toEqual([ + "description", + "type", + "location", + "paymentAccount", + "paymentDate", + "processingBegan", + "relatedInvoice", + "rowCount", + ]); + }); + + it("locks the ten Action filter options", () => { + expect(INVOICE_ACTION_OPTIONS).toHaveLength(10); + expect(INVOICE_ACTION_OPTIONS).toContain("Approve invoice"); + }); +}); diff --git a/src/dashboard/column-catalog.ts b/src/dashboard/column-catalog.ts new file mode 100644 index 0000000..4ef3d06 --- /dev/null +++ b/src/dashboard/column-catalog.ts @@ -0,0 +1,77 @@ +/** Locked Stampli HTML column catalog (AP-41 / AP-47). */ + +export type InvoiceColumnKey = + | "bolt" + | "bell" + | "invoiceId" + | "userStatus" + | "vendor" + | "lastUpdate" + | "invoiceDate" + | "dueDate" + | "amount" + | "tagging" + | "fs" + | "eye" + | "description" + | "type" + | "location" + | "paymentAccount" + | "paymentDate" + | "processingBegan" + | "relatedInvoice" + | "rowCount"; + +export type InvoiceColumnCatalogEntry = { + key: InvoiceColumnKey; + label: string; + locked: boolean; + defaultVisible: boolean; +}; + +export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [ + { key: "bolt", label: "Bolt", locked: true, defaultVisible: true }, + { key: "bell", label: "Bell", locked: true, defaultVisible: true }, + { key: "invoiceId", label: "Invoice", locked: true, defaultVisible: true }, + { key: "userStatus", label: "Action", locked: false, defaultVisible: true }, + { key: "vendor", label: "Vendor", locked: true, defaultVisible: true }, + { key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true }, + { key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true }, + { key: "dueDate", label: "Due Date", locked: false, defaultVisible: true }, + { key: "amount", label: "Σ Sum", locked: false, defaultVisible: true }, + { key: "tagging", label: "Tagging", locked: false, defaultVisible: true }, + { key: "fs", label: "F.S.", locked: false, defaultVisible: true }, + { key: "eye", label: "Eye", locked: false, defaultVisible: true }, + { key: "description", label: "Description", locked: false, defaultVisible: false }, + { key: "type", label: "Invoice Type", locked: false, defaultVisible: false }, + { key: "location", label: "Location", locked: false, defaultVisible: false }, + { key: "paymentAccount", label: "Payment Account", locked: false, defaultVisible: false }, + { key: "paymentDate", label: "Payment Date", locked: false, defaultVisible: false }, + { key: "processingBegan", label: "Processing Began", locked: false, defaultVisible: false }, + { key: "relatedInvoice", label: "Related Invoice", locked: false, defaultVisible: false }, + { key: "rowCount", label: "Row count", locked: false, defaultVisible: false }, +]; + +export const DEFAULT_VISIBLE_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter( + (entry) => entry.defaultVisible, +).map((entry) => entry.key); + +export const LOCKED_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter( + (entry) => entry.locked, +).map((entry) => entry.key); + +/** Locked Action filter options from Stampli HTML (AP-41 / AP-43). */ +export const INVOICE_ACTION_OPTIONS = [ + "A field update request has been submitted", + "A question has been submitted", + "Approve invoice", + "Attach receipt", + "Filed for my attention", + "Inquiry for you", + "Recalled invoice", + "Received response", + "View Comment", + "Waiting for response", +] as const; + +export type InvoiceActionOption = (typeof INVOICE_ACTION_OPTIONS)[number]; diff --git a/src/dashboard/column-prefs.test.ts b/src/dashboard/column-prefs.test.ts new file mode 100644 index 0000000..c1ba789 --- /dev/null +++ b/src/dashboard/column-prefs.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + COLUMN_PREFS_STORAGE_KEY, + defaultColumnPrefs, + hiddenColumnKeys, + loadColumnPrefs, + resetColumnPrefs, + saveColumnPrefs, +} from "@/dashboard/column-prefs"; +import { DEFAULT_VISIBLE_COLUMN_KEYS } from "@/dashboard/column-catalog"; + +describe("column-prefs", () => { + afterEach(() => { + sessionStorage.removeItem(COLUMN_PREFS_STORAGE_KEY); + }); + + it("defaults to HTML Visible order", () => { + expect(defaultColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]); + }); + + it("persists and reloads prefs", () => { + saveColumnPrefs({ visibleOrder: ["invoiceId", "vendor", "dueDate"] }); + expect(loadColumnPrefs().visibleOrder).toEqual([ + "bolt", + "bell", + "invoiceId", + "vendor", + "dueDate", + ]); + }); + + it("reset restores defaults", () => { + saveColumnPrefs({ visibleOrder: ["invoiceId"] }); + expect(resetColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]); + }); + + it("computes hidden keys from visible order", () => { + const hidden = hiddenColumnKeys(["invoiceId", "vendor"]); + expect(hidden).toContain("tagging"); + expect(hidden).not.toContain("invoiceId"); + }); +}); diff --git a/src/dashboard/column-prefs.ts b/src/dashboard/column-prefs.ts new file mode 100644 index 0000000..da750ea --- /dev/null +++ b/src/dashboard/column-prefs.ts @@ -0,0 +1,66 @@ +import { + DEFAULT_VISIBLE_COLUMN_KEYS, + INVOICE_COLUMN_CATALOG, + LOCKED_COLUMN_KEYS, + type InvoiceColumnKey, +} from "@/dashboard/column-catalog"; + +export const COLUMN_PREFS_STORAGE_KEY = "seahaven-ap.invoice-columns.v1"; + +export type ColumnPrefs = { + visibleOrder: InvoiceColumnKey[]; +}; + +export function defaultColumnPrefs(): ColumnPrefs { + return { visibleOrder: [...DEFAULT_VISIBLE_COLUMN_KEYS] }; +} + +export function loadColumnPrefs(): ColumnPrefs { + if (typeof sessionStorage === "undefined") { + return defaultColumnPrefs(); + } + try { + const raw = sessionStorage.getItem(COLUMN_PREFS_STORAGE_KEY); + if (!raw) { + return defaultColumnPrefs(); + } + const parsed = JSON.parse(raw) as ColumnPrefs; + if (!Array.isArray(parsed.visibleOrder) || parsed.visibleOrder.length === 0) { + return defaultColumnPrefs(); + } + const known = new Set(INVOICE_COLUMN_CATALOG.map((entry) => entry.key)); + const visibleOrder = parsed.visibleOrder.filter((key) => known.has(key)); + const missingLocked = LOCKED_COLUMN_KEYS.filter((key) => !visibleOrder.includes(key)); + return { visibleOrder: [...missingLocked, ...visibleOrder] }; + } catch { + return defaultColumnPrefs(); + } +} + +export function saveColumnPrefs(prefs: ColumnPrefs): void { + if (typeof sessionStorage === "undefined") { + return; + } + sessionStorage.setItem(COLUMN_PREFS_STORAGE_KEY, JSON.stringify(prefs)); +} + +export function resetColumnPrefs(): ColumnPrefs { + const prefs = defaultColumnPrefs(); + saveColumnPrefs(prefs); + return prefs; +} + +export function hiddenColumnKeys(visibleOrder: InvoiceColumnKey[]): InvoiceColumnKey[] { + const visible = new Set(visibleOrder); + return INVOICE_COLUMN_CATALOG.filter((entry) => !visible.has(entry.key)).map( + (entry) => entry.key, + ); +} + +export function isColumnLocked(key: InvoiceColumnKey): boolean { + return LOCKED_COLUMN_KEYS.includes(key); +} + +export function catalogLabel(key: InvoiceColumnKey): string { + return INVOICE_COLUMN_CATALOG.find((entry) => entry.key === key)?.label ?? key; +} diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts new file mode 100644 index 0000000..a3eb9d3 --- /dev/null +++ b/src/dashboard/columns.test.ts @@ -0,0 +1,48 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import { describe, expect, it } from "vitest"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; +import type { MockInvoice } from "@/mocks"; +import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; + +describe("invoice column date helpers (AP-47)", () => { + it("formats ISO dates as MM/DD/YYYY", () => { + expect(formatSlashDate("2026-04-10")).toBe("04/10/2026"); + }); + + it("formats relative ages from a fixed now", () => { + const now = new Date("2026-08-11T12:00:00"); + expect(formatRelativeFromIsoDate("2026-08-11", now)).toBe("today"); + expect(formatRelativeFromIsoDate("2026-08-10", now)).toBe("1 day ago"); + expect(formatRelativeFromIsoDate("2026-02-11", now)).toBe("6 months ago"); + }); + + it("uses calendar days so early hours still treat yesterday as 1 day ago", () => { + const earlyMorning = new Date("2026-08-11T01:15:00"); + expect(formatRelativeFromIsoDate("2026-08-10", earlyMorning)).toBe("1 day ago"); + expect(formatRelativeFromIsoDate("2026-08-11", earlyMorning)).toBe("today"); + }); + + it("formats future invoice and due dates instead of clamping to today", () => { + const now = new Date("2026-08-11T12:00:00"); + expect(formatRelativeFromIsoDate("2026-08-12", now)).toBe("in 1 day"); + expect(formatRelativeFromIsoDate("2026-08-16", now)).toBe("in 5 days"); + expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months"); + }); +}); + +describe("withEditablePayDate", () => { + const hidden: GridColDef = { field: "vendorName", headerName: "Vendor" }; + const payDate: GridColDef = { field: "payDate", headerName: "Payment Date" }; + + it("leaves Payment Date hidden when the catalog omits it", () => { + const columns = withEditablePayDate([hidden]); + expect(columns.some((column) => column.field === "payDate")).toBe(false); + }); + + it("keeps a single editable pay date when Payment Date is already visible", () => { + const columns = withEditablePayDate([hidden, { ...payDate, editable: false }]); + const matches = columns.filter((column) => column.field === "payDate"); + expect(matches).toHaveLength(1); + expect(matches[0]?.editable).toBe(true); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 686468e..c720972 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,35 +1,43 @@ -import { Link as MuiLink } from "@mui/material"; +import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { Box, Link as MuiLink, Stack, Typography } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; +import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; +import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; import type { MockInvoice } from "@/mocks"; import { Link } from "@/router"; -const currencyFormatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}); - -const dateFormatter = new Intl.DateTimeFormat("en-US", { +const dateTimeFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", - month: "short", - day: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, }); -function formatIsoDate(value: string | null | undefined): string { - if (!value) { +function formatAmountUsd(value: number | null | undefined): string { + if (value == null) { return "—"; } - const parsed = new Date(`${value}T00:00:00`); - if (Number.isNaN(parsed.getTime())) { - return value; - } - return dateFormatter.format(parsed); + return `${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} USD`; +} + +function formatIsoDate(value: string | null | undefined): string { + return formatSlashDate(value); } function formatCurrency(value: number | null | undefined): string { if (value == null) { return "—"; } - return currencyFormatter.format(value); + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); } const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef => ({ @@ -54,6 +62,22 @@ const dateColumn = ( valueFormatter: (value: string | null | undefined) => formatIsoDate(value), }); +function twoLineCell(primary: string, secondary: string, primaryBold = false) { + return ( + + + {primary} + + + {secondary} + + + ); +} + function invoiceNumberLink(params: GridRenderCellParams) { return ( ) { underline="hover" data-testid={`invoice-link-${params.row.id}`} onClick={(event) => event.stopPropagation()} + sx={{ fontWeight: 700, color: "text.primary" }} > {params.value} @@ -95,17 +120,142 @@ export const invoiceAttentionColumns: GridColDef[] = [ renderCell: invoiceNumberLink, }, { - field: "status", + field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140, - valueFormatter: (value: string) => (value === "pending_approval" ? "Approve invoice" : value), }, { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, dateColumn("dueDate", "Due Date"), moneyColumn("amountDue", "Σ Sum"), ]; +const iconColumn = ( + field: "hasBolt" | "hasBell" | "hasEye", + Icon: typeof BoltOutlinedIcon, +): GridColDef => ({ + field, + headerName: "", + width: 56, + sortable: false, + filterable: false, + renderHeader: () => , + renderCell: (params) => (params.value ? : null), +}); + +function dualDateColumn( + field: "invoiceDate" | "dueDate", + headerName: string, +): GridColDef { + return { + field, + headerName, + flex: 1, + minWidth: 120, + renderCell: (params) => + twoLineCell( + formatSlashDate(params.value as string), + formatRelativeFromIsoDate(params.value as string), + ), + }; +} + +/** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */ +export const catalogColumnDefs: Record> = { + bolt: iconColumn("hasBolt", BoltOutlinedIcon), + bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon), + invoiceId: { + field: "invoiceNumbers", + headerName: "Invoice", + flex: 1, + minWidth: 120, + renderCell: invoiceNumberLink, + }, + userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 }, + vendor: { + field: "vendorName", + headerName: "Vendor", + flex: 1.4, + minWidth: 160, + renderCell: (params) => twoLineCell(params.row.vendorName, params.row.vendorId, true), + }, + lastUpdate: { + field: "lastUpdate", + headerName: "Last Update", + flex: 1.6, + minWidth: 180, + renderCell: (params) => { + const stamp = dateTimeFormatter.format(new Date(params.row.lastUpdateAt)); + return twoLineCell( + params.row.lastUpdateMessage, + `${stamp} ${params.row.lastUpdateActorName}`, + ); + }, + }, + invoiceDate: dualDateColumn("invoiceDate", "Invoice Date"), + dueDate: dualDateColumn("dueDate", "Due Date"), + amount: { + field: "amountDue", + headerName: "Σ Sum", + flex: 1, + minWidth: 120, + type: "number", + renderHeader: () => ( + + Σ Sum + + ), + renderCell: (params) => ( + + {formatAmountUsd(params.row.amountDue)} + + ), + }, + tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 }, + fs: { + field: "fs", + headerName: "F.S.", + flex: 0.6, + minWidth: 80, + renderCell: (params) => + params.row.fs ? : null, + }, + eye: iconColumn("hasEye", VisibilityOutlinedIcon), + description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 }, + type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 }, + location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 }, + paymentAccount: { + field: "paymentAccount", + headerName: "Payment Account", + flex: 1.2, + minWidth: 140, + }, + paymentDate: dateColumn("payDate", "Payment Date"), + processingBegan: dateColumn("processingBegan", "Processing Began"), + relatedInvoice: { + field: "relatedInvoice", + headerName: "Related Invoice", + flex: 1, + minWidth: 120, + valueFormatter: (value: string | null) => value ?? "—", + }, + rowCount: { + field: "rowCount", + headerName: "Row count", + width: 100, + type: "number", + }, +}; + +export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef[] { + return keys.map((key) => catalogColumnDefs[key]); +} + +/** Approved tab keeps pay date editable when the drawer shows Payment Date. */ +export function columnsForApprovedGrid(keys: InvoiceColumnKey[]): GridColDef[] { + return withEditablePayDate(columnsFromCatalogKeys(keys)); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber", diff --git a/src/dashboard/invoice-date-format.ts b/src/dashboard/invoice-date-format.ts new file mode 100644 index 0000000..f02ade0 --- /dev/null +++ b/src/dashboard/invoice-date-format.ts @@ -0,0 +1,56 @@ +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function formatUnit(count: number, unit: "day" | "month" | "year", future: boolean): string { + const noun = count === 1 ? unit : `${unit}s`; + return future ? `in ${count} ${noun}` : `${count} ${noun} ago`; +} + +export function formatSlashDate(value: string | null | undefined): string { + if (!value) { + return "—"; + } + const parsed = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(parsed); +} + +export function formatRelativeFromIsoDate( + value: string | null | undefined, + now: Date = new Date(), +): string { + if (!value) { + return ""; + } + const parsed = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + + const dayMs = 24 * 60 * 60 * 1000; + const days = Math.round( + (startOfLocalDay(now).getTime() - startOfLocalDay(parsed).getTime()) / dayMs, + ); + if (days === 0) { + return "today"; + } + + const future = days < 0; + const absDays = Math.abs(days); + if (absDays < 30) { + return formatUnit(absDays, "day", future); + } + const months = Math.floor(absDays / 30); + if (months < 12) { + return formatUnit(months, "month", future); + } + const years = Math.floor(months / 12); + return formatUnit(years, "year", future); +} diff --git a/src/dashboard/invoice-filters.ts b/src/dashboard/invoice-filters.ts new file mode 100644 index 0000000..5504bcc --- /dev/null +++ b/src/dashboard/invoice-filters.ts @@ -0,0 +1,17 @@ +export type InvoiceFilterState = { + action: string | null; + vendorQuery: string; +}; + +export function countActiveFilters(filters: InvoiceFilterState): number { + let count = 0; + if (filters.action) { + count += 1; + } + if (filters.vendorQuery.trim()) { + count += 1; + } + return count; +} + +export const EMPTY_INVOICE_FILTERS: InvoiceFilterState = { action: null, vendorQuery: "" }; diff --git a/src/dashboard/invoice-tabs.tsx b/src/dashboard/invoice-tabs.tsx new file mode 100644 index 0000000..605ba29 --- /dev/null +++ b/src/dashboard/invoice-tabs.tsx @@ -0,0 +1,42 @@ +import ErrorOutlinedIcon from "@mui/icons-material/ErrorOutlined"; +import { Box, Stack } from "@mui/material"; +import type { ReactElement } from "react"; + +export function InvoiceTabLabel({ + title, + count, + active = false, + urgency = false, +}: { + title: string; + count: number; + active?: boolean; + urgency?: boolean; +}): ReactElement { + return ( + + {title} + {urgency ? ( + + ) : null} + + {count} + + + ); +} diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index f3e8692..0729bd8 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -54,6 +54,58 @@ describe("mocks module", () => { expect(getMockInvoices().length).toBeGreaterThanOrEqual(6); }); + it("seeds Stampli catalog fields on every invoice (AP-47)", () => { + const invoices = getMockInvoices(); + expect(invoices.length).toBeGreaterThan(0); + for (const invoice of invoices) { + expect(typeof invoice.hasBolt).toBe("boolean"); + expect(typeof invoice.hasBell).toBe("boolean"); + expect(typeof invoice.hasEye).toBe("boolean"); + expect(invoice.actionLabel.length).toBeGreaterThan(0); + expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(invoice.lastUpdateMessage.length).toBeGreaterThan(0); + expect(invoice.lastUpdateActorName.length).toBeGreaterThan(0); + expect(invoice.lastUpdateAt.length).toBeGreaterThan(0); + expect(invoice.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(invoice.invoiceDate).not.toBe(invoice.dueDate); + expect(invoice.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(typeof invoice.tagging).toBe("string"); + expect(typeof invoice.fs).toBe("string"); + expect(typeof invoice.description).toBe("string"); + expect(typeof invoice.invoiceType).toBe("string"); + expect(typeof invoice.location).toBe("string"); + expect(typeof invoice.paymentAccount).toBe("string"); + expect(invoice.relatedInvoice === null || typeof invoice.relatedInvoice === "string").toBe( + true, + ); + expect(invoice.rowCount).toBe(invoice.lines.length); + } + const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); + expect(pending.every((invoice) => invoice.actionLabel === "Approve invoice")).toBe(true); + const nonPending = invoices.filter((invoice) => invoice.status !== "pending_approval"); + expect(nonPending.every((invoice) => invoice.actionLabel !== "Approve invoice")).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "approved") + .every((invoice) => invoice.actionLabel === "Approved"), + ).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "paid") + .every((invoice) => invoice.actionLabel === "Paid"), + ).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "scheduled") + .every((invoice) => invoice.actionLabel === "Scheduled"), + ).toBe(true); + expect( + invoices + .filter((invoice) => invoice.status === "rejected") + .every((invoice) => invoice.actionLabel === "Rejected"), + ).toBe(true); + }); + it("seeds at least two invoices per dashboard tab", () => { const invoices = getMockInvoices(); const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); @@ -70,6 +122,8 @@ describe("mocks module", () => { const updated = updateMockInvoicePayDate("inv-1", "2026-08-18"); expect(updated.payDate).toBe("2026-08-18"); + expect(updated.lastUpdateMessage).toBe("Pay date updated"); + expect(updated.lastUpdateAt).not.toBe(before?.lastUpdateAt); const after = getMockInvoices().find((invoice) => invoice.id === "inv-1"); expect(after?.payDate).toBe("2026-08-18"); @@ -94,6 +148,7 @@ describe("mocks module", () => { const updated = updateMockInvoiceHeader("inv-2", { memo: "Updated memo", amountDue: 4700 }); expect(updated.memo).toBe("Updated memo"); expect(updated.amountDue).toBe(4700); + expect(updated.description).toBe("Updated memo"); const withLines = updateMockInvoiceLines("inv-2", [ { @@ -106,7 +161,9 @@ describe("mocks module", () => { ]); expect(withLines.lines).toHaveLength(1); expect(withLines.lines[0].description).toBe("Updated line"); + expect(withLines.rowCount).toBe(1); expect(getMockInvoice("inv-2")?.lines[0].description).toBe("Updated line"); + expect(getMockInvoice("inv-2")?.rowCount).toBe(1); }); it("adds comments and transitions status with activity", () => { @@ -114,6 +171,7 @@ describe("mocks module", () => { authorUserId: "user-1", authorName: "Alex Rivera", body: "Need another look", + createdAt: "2026-08-11T09:00:00.000Z", }); expect(withComment.comments.some((comment) => comment.body === "Need another look")).toBe(true); @@ -121,13 +179,57 @@ describe("mocks module", () => { actorUserId: "user-2", actorName: "Jordan Lee", message: "Approved", + createdAt: "2026-08-12T09:00:00.000Z", }); expect(approved.status).toBe("approved"); expect(approved.activity.some((entry) => entry.message === "Approved")).toBe(true); + expect(approved.lastUpdateMessage).toBe("Approved"); + expect(approved.lastUpdateActorName).toBe("Jordan Lee"); + expect(approved.lastUpdateAt).toBe("2026-08-12T09:00:00.000Z"); + expect(approved.lastUpdate).toBe("2026-08-12"); + expect(approved.actionLabel).not.toBe("Approve invoice"); expect(getMockPendingApprovals().some((item) => item.invoiceId === "inv-2")).toBe(false); expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved"); }); + it("refreshes last update from a comment and payment account from the method", () => { + const commented = addMockInvoiceComment("inv-1", { + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Checked the total", + createdAt: "2026-09-01T15:00:00.000Z", + }); + expect(commented.lastUpdateAt).toBe("2026-09-01T15:00:00.000Z"); + expect(commented.lastUpdate).toBe("2026-09-01"); + expect(commented.lastUpdateMessage).toBe("Checked the total"); + expect(commented.lastUpdateActorName).toBe("Alex Rivera"); + + const method = updateMockInvoiceHeader("inv-1", { paymentMethod: "ach" }); + expect(method.paymentAccount).toBe("Operating ACH"); + expect(getMockInvoice("inv-1")?.paymentAccount).toBe("Operating ACH"); + }); + + it("refreshes description and rowCount when header or lines change", () => { + const before = getMockInvoice("inv-1"); + expect(before?.rowCount).toBe(2); + expect(before?.description).toBe("Office supplies restock"); + + const header = updateMockInvoiceHeader("inv-1", { memo: "Revised supplies memo" }); + expect(header.description).toBe("Revised supplies memo"); + + const lines = updateMockInvoiceLines("inv-1", [ + { + id: "inv-1-line-1", + description: "Only line", + amount: 100, + glAccountId: "gl-1", + departmentId: "dept-4", + }, + ]); + expect(lines.rowCount).toBe(1); + expect(getMockInvoice("inv-1")?.rowCount).toBe(1); + }); + it("returns undefined for unknown invoice ids", () => { expect(getMockInvoice("missing-invoice")).toBeUndefined(); }); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index c324ad7..8f7b4b5 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -45,6 +45,24 @@ export type MockInvoice = { lines: MockInvoiceLine[]; comments: MockInvoiceComment[]; activity: MockInvoiceActivity[]; + hasBolt: boolean; + hasBell: boolean; + hasEye: boolean; + actionLabel: string; + lastUpdate: string; + lastUpdateMessage: string; + lastUpdateActorName: string; + lastUpdateAt: string; + invoiceDate: string; + processingBegan: string; + tagging: string; + fs: string; + description: string; + invoiceType: string; + location: string; + paymentAccount: string; + relatedInvoice: string | null; + rowCount: number; }; export type MockInvoiceHeaderPatch = Partial< @@ -63,6 +81,116 @@ export type MockInvoiceHeaderPatch = Partial< const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf"; +type MockInvoiceCore = Omit< + MockInvoice, + | "hasBolt" + | "hasBell" + | "hasEye" + | "actionLabel" + | "lastUpdate" + | "lastUpdateMessage" + | "lastUpdateActorName" + | "lastUpdateAt" + | "invoiceDate" + | "processingBegan" + | "tagging" + | "fs" + | "description" + | "invoiceType" + | "location" + | "paymentAccount" + | "relatedInvoice" + | "rowCount" +>; + +const ACTION_LABEL_BY_STATUS: Record = { + pending_approval: "Approve invoice", + approved: "Approved", + scheduled: "Scheduled", + paid: "Paid", + rejected: "Rejected", +}; + +function actionLabelForStatus(status: MockInvoiceStatus): string { + return ACTION_LABEL_BY_STATUS[status]; +} + +function derivedActivityFields(invoice: Pick) { + const events = [ + ...invoice.activity.map((entry) => ({ + at: entry.createdAt, + message: entry.message, + actor: entry.actorName, + })), + ...invoice.comments.map((comment) => ({ + at: comment.createdAt, + message: comment.body, + actor: comment.authorName, + })), + ]; + const latest = events.reduce<(typeof events)[number] | undefined>((current, event) => { + if (!current || event.at > current.at) { + return event; + } + return current; + }, undefined); + const lastUpdateAt = latest?.at ?? `${invoice.dueDate}T12:00:00.000Z`; + return { + lastUpdate: lastUpdateAt.slice(0, 10), + lastUpdateMessage: latest?.message ?? "Invoice updated", + lastUpdateActorName: latest?.actor ?? "System", + lastUpdateAt, + }; +} + +function paymentAccountFor(paymentMethod: MockInvoice["paymentMethod"]): string { + return paymentMethod === "ach" ? "Operating ACH" : "Operating Check"; +} + +/** Invoice date is independent of the due date. Offset varies per seed. */ +function invoiceDateBeforeDue(dueDate: string, index: number): string { + const [year, month, day] = dueDate.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() - (14 + index)); + return date.toISOString().slice(0, 10); +} + +/** Keep catalog-derived fields in sync after mock mutations. */ +function withRefreshedCatalogFields(invoice: MockInvoice): MockInvoice { + return { + ...invoice, + actionLabel: actionLabelForStatus(invoice.status), + ...derivedActivityFields(invoice), + description: invoice.memo, + paymentAccount: paymentAccountFor(invoice.paymentMethod), + rowCount: invoice.lines.length, + }; +} + +function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { + return withRefreshedCatalogFields({ + ...core, + hasBolt: index % 3 === 0, + hasBell: index % 2 === 0, + hasEye: true, + actionLabel: actionLabelForStatus(core.status), + lastUpdate: "", + lastUpdateMessage: "", + lastUpdateActorName: "", + lastUpdateAt: "", + invoiceDate: invoiceDateBeforeDue(core.dueDate, index), + processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, + tagging: index % 2 === 0 ? "Ops" : "", + fs: index % 2 === 0 ? "synced" : "", + description: core.memo, + invoiceType: "Standard", + location: "Ronkonkoma", + paymentAccount: paymentAccountFor(core.paymentMethod), + relatedInvoice: null, + rowCount: core.lines.length, + }); +} + function cloneInvoice(invoice: MockInvoice): MockInvoice { return { ...invoice, @@ -72,7 +200,7 @@ function cloneInvoice(invoice: MockInvoice): MockInvoice { }; } -const seedInvoices: MockInvoice[] = [ +const seedInvoiceCores: MockInvoiceCore[] = [ { id: "inv-1", invoiceNumber: "INV-1001", @@ -414,6 +542,10 @@ const seedInvoices: MockInvoice[] = [ }, ]; +const seedInvoices: MockInvoice[] = seedInvoiceCores.map((core, index) => + enrichInvoice(core, index), +); + /** Mutable in-memory invoice store for mock POC interactions. */ let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => cloneInvoice(invoice)); @@ -443,7 +575,24 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { ...invoiceStore[index], payDate }); + const current = invoiceStore[index]; + return replaceInvoice( + index, + withRefreshedCatalogFields({ + ...current, + payDate, + activity: [ + ...current.activity, + { + id: `activity-pay-date-${Date.now()}`, + actorUserId: "user-1", + actorName: "System", + message: "Pay date updated", + createdAt: new Date().toISOString(), + }, + ], + }), + ); } export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice { @@ -452,7 +601,7 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { ...invoiceStore[index], ...patch }); + return replaceInvoice(index, withRefreshedCatalogFields({ ...invoiceStore[index], ...patch })); } export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice { @@ -461,10 +610,13 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { - ...invoiceStore[index], - lines: lines.map((line) => ({ ...line })), - }); + return replaceInvoice( + index, + withRefreshedCatalogFields({ + ...invoiceStore[index], + lines: lines.map((line) => ({ ...line })), + }), + ); } export function addMockInvoiceComment( @@ -488,10 +640,13 @@ export function addMockInvoiceComment( createdAt: comment.createdAt ?? new Date().toISOString(), }; - return replaceInvoice(index, { - ...current, - comments: [...current.comments, nextComment], - }); + return replaceInvoice( + index, + withRefreshedCatalogFields({ + ...current, + comments: [...current.comments, nextComment], + }), + ); } export function transitionMockInvoiceStatus( @@ -516,11 +671,14 @@ export function transitionMockInvoiceStatus( createdAt: activity.createdAt ?? new Date().toISOString(), }; - const updated = replaceInvoice(index, { - ...current, - status, - activity: [...current.activity, nextActivity], - }); + const updated = replaceInvoice( + index, + withRefreshedCatalogFields({ + ...current, + status, + activity: [...current.activity, nextActivity], + }), + ); if (status === "approved" || status === "rejected") { syncMockApprovalsForInvoice(id, status);