merge(cd): keep dev deploy docs on main

This commit is contained in:
Adam Moussa 2026-09-25 18:48:15 -04:00
commit 91e8740eca
No known key found for this signature in database
31 changed files with 1756 additions and 335 deletions

View file

@ -1 +0,0 @@
extends: .github

View file

@ -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'

View file

@ -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");
});
});

View file

@ -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("");
});
});

View file

@ -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");
});
});

View file

@ -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.",
);
});
});

351
package-lock.json generated
View file

@ -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"

View file

@ -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"
}
}

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"

View file

@ -23,7 +23,7 @@
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.11"
"vitest": "^5.0.1"
},
"engines": {
"node": ">=24.21.0"

View file

@ -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 (
<Box ref={setNodeRef} style={style} data-testid={`columns-visible-${columnKey}`}>
<IconButton size="small" {...attributes} {...listeners} aria-label={`Reorder ${columnKey}`}>
<DragIndicatorIcon fontSize="small" />
</IconButton>
<FormControlLabel
control={
<Checkbox
checked
disabled={locked}
onChange={(event) => onToggle(columnKey, event.target.checked)}
/>
}
label={catalogLabel(columnKey)}
/>
</Box>
);
}
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 (
<Drawer
anchor="right"
open={open}
onClose={onClose}
data-testid="columns-drawer"
slotProps={{ paper: { sx: { width: 360, p: 2 } } }}
>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6" data-testid="columns-selector-header">
Columns
</Typography>
<Stack direction="row" spacing={1}>
<Button
size="small"
data-testid="columns-selector-reset-button"
onClick={() => {
setHiddenQuery("");
setViewAllHidden(false);
onReset();
}}
>
Reset
</Button>
<IconButton aria-label="Close columns" onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Stack>
</Stack>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Visible
</Typography>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={prefs.visibleOrder} strategy={verticalListSortingStrategy}>
<Stack spacing={0.5} sx={{ mb: 2 }}>
{prefs.visibleOrder.map((key) => (
<SortableVisibleRow key={key} columnKey={key} onToggle={onToggle} />
))}
</Stack>
</SortableContext>
</DndContext>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Hidden
</Typography>
<TextField
size="small"
fullWidth
placeholder="Search"
value={hiddenQuery}
onChange={(event) => setHiddenQuery(event.target.value)}
sx={{ mb: 1 }}
slotProps={{ htmlInput: { "data-testid": "columns-hidden-search" } }}
/>
<Stack spacing={0.5} sx={{ mb: 1 }}>
{filteredHidden.map((key) => (
<FormControlLabel
key={key}
data-testid={`columns-hidden-${key}`}
control={
<Checkbox checked={false} onChange={(event) => onToggle(key, event.target.checked)} />
}
label={catalogLabel(key)}
/>
))}
</Stack>
{!viewAllHidden && !hiddenQuery && hidden.length > 5 ? (
<Button size="small" onClick={() => setViewAllHidden(true)}>
View all
</Button>
) : null}
</Drawer>
);
}

View file

@ -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<InvoiceTab>("attention");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const [filters, setFilters] = useState<InvoiceFilterState>(emptyFilters);
const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [columnPrefs, setColumnPrefs] = useState<ColumnPrefs>(() => 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<MockInvoice>) => {
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 (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
@ -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}
>
<Tab
label={`For My Attention (${attentionRows.length})`}
label={
<InvoiceTabLabel
title="For My Attention"
count={filteredAttention.length}
active={tab === "attention"}
/>
}
value="attention"
data-testid="tab-pending"
/>
<Tab label={`New Invoices (${invoices.length})`} value="all" data-testid="tab-all" />
<Tab
label={`Approved Invoices (${approvedRows.length})`}
label={
<InvoiceTabLabel
title="New Invoices"
count={filteredAll.length}
active={tab === "all"}
/>
}
value="all"
data-testid="tab-all"
/>
<Tab
label={
<InvoiceTabLabel
title="Approved Invoices"
count={filteredApproved.length}
active={tab === "approved"}
/>
}
value="approved"
data-testid="tab-ready"
/>
<Tab
label={`Rejected Invoices (${rejectedRows.length})`}
label={
<InvoiceTabLabel
title="Rejected Invoices"
count={filteredRejected.length}
active={tab === "rejected"}
/>
}
value="rejected"
data-testid="tab-rejected"
/>
<Tab label={`Late Invoices (${lateRows.length})`} value="late" data-testid="tab-late" />
<Tab
label={
<InvoiceTabLabel
title="Late Invoices"
count={filteredLate.length}
active={tab === "late"}
urgency
/>
}
value="late"
data-testid="tab-late"
/>
</Tabs>
</Box>
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
{attentionRows.length > 0 && tab === "attention" ? (
<Alert severity="info" sx={{ mb: 2 }} data-testid="attention-banner">
{attentionRows.length} invoices require approval. Show Invoices ready to approve.
</Alert>
{filteredAttention.length > 0 && tab === "attention" ? (
<Typography
data-testid="attention-banner"
variant="body2"
sx={{ display: "block", mb: 1.5, color: "primary.main", textAlign: "left" }}
>
{filteredAttention.length} invoices require approval.{" "}
<Box component="span" sx={{ textDecoration: "underline" }}>
Show invoices ready to approve.
</Box>
</Typography>
) : null}
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
mb: 2,
}}
>
<Chip label="Action" variant="outlined" size="small" />
<Chip label="Vendor" variant="outlined" size="small" />
<Box sx={{ flexGrow: 1 }} />
<Button variant="contained" size="small" data-testid="review-all-items">
Review All Items
</Button>
{tab === "approved" ? <CreatePayRunButton selectedCount={selectedCount} /> : null}
</Stack>
<InvoiceFilterToolbar
filters={filters}
onFiltersChange={setFilters}
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)}
trailingActions={
tab === "approved" ? <CreatePayRunButton selectedCount={selectedCount} /> : null
}
/>
{tab === "attention" && (
<InvoiceDataGrid
data-testid="grid-pending"
rows={attentionRows}
columns={invoiceAttentionColumns}
rows={filteredAttention}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -182,8 +273,8 @@ export function DashboardGrids() {
{tab === "all" && (
<InvoiceDataGrid
data-testid="grid-all"
rows={invoices}
columns={allInvoicesColumns}
rows={filteredAll}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -192,8 +283,8 @@ export function DashboardGrids() {
{tab === "approved" && (
<InvoiceDataGrid
data-testid="grid-ready"
rows={approvedRows}
columns={readyToPayColumns}
rows={filteredApproved}
columns={approvedColumns}
checkboxSelection
rowSelectionModel={selection}
onRowSelectionModelChange={(model) => setSelection(model)}
@ -208,8 +299,8 @@ export function DashboardGrids() {
{tab === "rejected" && (
<InvoiceDataGrid
data-testid="grid-rejected"
rows={rejectedRows}
columns={allInvoicesColumns}
rows={filteredRejected}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -218,8 +309,8 @@ export function DashboardGrids() {
{tab === "late" && (
<InvoiceDataGrid
data-testid="grid-late"
rows={lateRows}
columns={allInvoicesColumns}
rows={filteredLate}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
@ -229,6 +320,23 @@ export function DashboardGrids() {
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
</Typography>
</Box>
<ColumnsDrawer
open={columnsDrawerOpen}
onClose={() => setColumnsDrawerOpen(false)}
prefs={columnPrefs}
onPrefsChange={updateColumnPrefs}
onReset={() => {
const next = resetColumnPrefs();
setColumnPrefs(next);
}}
/>
<FilterDrawer
open={filterDrawerOpen}
onClose={() => setFilterDrawerOpen(false)}
filters={filters}
onFiltersChange={setFilters}
/>
</Stack>
);
}

View file

@ -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 (
<Drawer
anchor="right"
open={open}
onClose={onClose}
data-testid="filter-drawer"
slotProps={{ paper: { sx: { width: 360, p: 2 } } }}
>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6">Filters</Typography>
<IconButton aria-label="Close filters" onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{activeCount === 0
? "No filters applied."
: `${activeCount} active filter${activeCount === 1 ? "" : "s"}.`}
</Typography>
<Stack spacing={1.5} sx={{ mb: 3 }}>
<Box>
<Typography variant="caption" color="text.secondary">
Action
</Typography>
<Typography variant="body2" data-testid="filter-drawer-action">
{filters.action ?? "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vendor
</Typography>
<Typography variant="body2" data-testid="filter-drawer-vendor">
{filters.vendorQuery.trim() || "—"}
</Typography>
</Box>
</Stack>
<Button
variant="outlined"
data-testid="filter-drawer-clear"
disabled={activeCount === 0}
onClick={() => onFiltersChange({ action: null, vendorQuery: "" })}
>
Clear all
</Button>
</Drawer>
);
}

View file

@ -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 (
<Stack spacing={1.5} sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} useFlexGap sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Box data-testid="action-filter" sx={{ ...fieldSx, minWidth: 220 }}>
<Autocomplete
size="small"
options={[...INVOICE_ACTION_OPTIONS]}
value={filters.action}
onChange={(_event, value) => onFiltersChange({ ...filters, action: value })}
renderInput={(params) => <TextField {...params} placeholder="Search" />}
/>
</Box>
<TextField
size="small"
name="vendor"
id="vendor"
placeholder="Vendor"
value={filters.vendorQuery}
onChange={(event) => onFiltersChange({ ...filters, vendorQuery: event.target.value })}
sx={fieldSx}
slotProps={{
htmlInput: { "data-testid": "vendor-filter", "aria-label": "Vendor" },
input: {
endAdornment: filters.vendorQuery ? (
<InputAdornment position="end">
<Button
size="small"
onClick={() => onFiltersChange({ ...filters, vendorQuery: "" })}
aria-label="Clear vendor"
>
Clear
</Button>
</InputAdornment>
) : undefined,
},
}}
/>
<Box sx={{ flexGrow: 1 }} />
<IconButton
size="small"
aria-label="Filter actions"
data-testid="filter-actions-menu"
id="default-filter-actions-menu"
>
<MoreVertIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Open filters"
data-testid="filter-toggle"
onClick={onOpenFilterDrawer}
>
<Badge badgeContent={activeCount || undefined} color="primary">
<FilterListIcon fontSize="small" />
</Badge>
</IconButton>
<IconButton
size="small"
aria-label="Open columns"
data-testid="columns-toggle"
onClick={onOpenColumnsDrawer}
>
<ViewWeekOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button variant="contained" size="small" data-testid="review-all-items">
Review All Items
</Button>
{trailingActions}
</Stack>
</Stack>
);
}

View file

@ -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<MockInvoice>[]): GridColDef<MockInvoice>[] {
return columns.map((column) =>
column.field === "payDate" ? { ...column, editable: true } : column,
);
}

View file

@ -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");
});
});

View file

@ -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];

View file

@ -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");
});
});

View file

@ -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;
}

View file

@ -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<MockInvoice> = { field: "vendorName", headerName: "Vendor" };
const payDate: GridColDef<MockInvoice> = { 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);
});
});

View file

@ -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<MockInvoice> => ({
@ -54,6 +62,22 @@ const dateColumn = (
valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
});
function twoLineCell(primary: string, secondary: string, primaryBold = false) {
return (
<Stack spacing={0.25} sx={{ py: 0.25, lineHeight: 1.3 }}>
<Typography
variant="body2"
sx={{ fontWeight: primaryBold ? 700 : 400, color: "text.primary" }}
>
{primary}
</Typography>
<Typography variant="caption" color="text.secondary">
{secondary}
</Typography>
</Stack>
);
}
function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
return (
<MuiLink
@ -63,6 +87,7 @@ function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
underline="hover"
data-testid={`invoice-link-${params.row.id}`}
onClick={(event) => event.stopPropagation()}
sx={{ fontWeight: 700, color: "text.primary" }}
>
{params.value}
</MuiLink>
@ -95,17 +120,142 @@ export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
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<MockInvoice> => ({
field,
headerName: "",
width: 56,
sortable: false,
filterable: false,
renderHeader: () => <Icon fontSize="small" color="action" />,
renderCell: (params) => (params.value ? <Icon fontSize="small" color="action" /> : null),
});
function dualDateColumn(
field: "invoiceDate" | "dueDate",
headerName: string,
): GridColDef<MockInvoice> {
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<InvoiceColumnKey, GridColDef<MockInvoice>> = {
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: () => (
<Box component="span" sx={{ fontWeight: 700 }}>
Σ Sum
</Box>
),
renderCell: (params) => (
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatAmountUsd(params.row.amountDue)}
</Typography>
),
},
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 ? <CheckCircleIcon fontSize="small" color="success" /> : 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<MockInvoice>[] {
return keys.map((key) => catalogColumnDefs[key]);
}
/** Approved tab keeps pay date editable when the drawer shows Payment Date. */
export function columnsForApprovedGrid(keys: InvoiceColumnKey[]): GridColDef<MockInvoice>[] {
return withEditablePayDate(columnsFromCatalogKeys(keys));
}
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
{
field: "invoiceNumber",

View file

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

View file

@ -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: "" };

View file

@ -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 (
<Stack direction="row" spacing={0.75} component="span" sx={{ alignItems: "center" }}>
<Box component="span">{title}</Box>
{urgency ? (
<ErrorOutlinedIcon sx={{ fontSize: 16, color: "error.main" }} aria-hidden />
) : null}
<Box
component="span"
sx={{
minWidth: 22,
height: 22,
px: 0.75,
borderRadius: 999,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 700,
bgcolor: active ? "#7B61FF" : urgency ? "error.main" : "primary.main",
color: "#fff",
}}
>
{count}
</Box>
</Stack>
);
}

View file

@ -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();
});

View file

@ -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<MockInvoiceStatus, string> = {
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<MockInvoice, "activity" | "comments" | "dueDate">) {
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);