mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-02 05:03:20 +00:00
merge(cd): keep dev deploy docs on main
This commit is contained in:
commit
91e8740eca
31 changed files with 1756 additions and 335 deletions
|
|
@ -1 +0,0 @@
|
|||
extends: .github
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
64
e2e/invoice-columns.spec.ts
Normal file
64
e2e/invoice-columns.spec.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
31
e2e/invoice-filter-drawer.spec.ts
Normal file
31
e2e/invoice-filter-drawer.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
24
e2e/invoice-filters.spec.ts
Normal file
24
e2e/invoice-filters.spec.ts
Normal 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
351
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.21.0"
|
||||
|
|
|
|||
207
src/dashboard/ColumnsDrawer.tsx
Normal file
207
src/dashboard/ColumnsDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
71
src/dashboard/FilterDrawer.tsx
Normal file
71
src/dashboard/FilterDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/dashboard/InvoiceFilterToolbar.tsx
Normal file
117
src/dashboard/InvoiceFilterToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/dashboard/approved-columns.ts
Normal file
9
src/dashboard/approved-columns.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
48
src/dashboard/column-catalog.test.ts
Normal file
48
src/dashboard/column-catalog.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
77
src/dashboard/column-catalog.ts
Normal file
77
src/dashboard/column-catalog.ts
Normal 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];
|
||||
42
src/dashboard/column-prefs.test.ts
Normal file
42
src/dashboard/column-prefs.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
src/dashboard/column-prefs.ts
Normal file
66
src/dashboard/column-prefs.ts
Normal 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;
|
||||
}
|
||||
48
src/dashboard/columns.test.ts
Normal file
48
src/dashboard/columns.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
56
src/dashboard/invoice-date-format.ts
Normal file
56
src/dashboard/invoice-date-format.ts
Normal 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);
|
||||
}
|
||||
17
src/dashboard/invoice-filters.ts
Normal file
17
src/dashboard/invoice-filters.ts
Normal 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: "" };
|
||||
42
src/dashboard/invoice-tabs.tsx
Normal file
42
src/dashboard/invoice-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue