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);