Compare commits

...

5 commits

Author SHA1 Message Date
dependabot[bot]
c86ae1fb1b chore(deps): bump react-router from 7.18.2 to 8.3.0
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.18.2 to 8.3.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@8.3.0/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 17:39:13 -04:00
dependabot[bot]
88c3a2560d
chore(deps): bump @eslint/js from 9.39.5 to 10.0.1 (#3)
* chore(deps): bump @eslint/js from 9.39.5 to 10.0.1

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.5 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): align eslint 10 with @eslint/js peer dependency

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-08-10 21:38:16 +00:00
Adam Moussa
28a91cb7a0
feat(frontend): add dashboard grids on mocks (AP-5) (#7) 2026-08-10 21:32:43 +00:00
dependabot[bot]
0136cfe5ad
chore(deps): bump @testing-library/jest-dom from 6.9.1 to 7.0.0 (#6)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.0.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 21:22:20 +00:00
dependabot[bot]
cf84852e37
chore(deps): bump eslint-plugin-react-refresh (#2)
Bumps the minor-and-patch group with 1 update: [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh).


Updates `eslint-plugin-react-refresh` from 0.4.26 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.26...v0.5.3)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 17:18:33 -04:00
14 changed files with 725 additions and 371 deletions

View file

@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
test.describe("dashboard grids", () => {
test("home page shows brand and three invoice grids", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
await expect(
page.getByTestId("grid-pending").locator(".MuiDataGrid-row").first(),
).toBeVisible();
await page.getByTestId("tab-ready").click();
await expect(page.getByTestId("grid-ready").locator(".MuiDataGrid-row").first()).toBeVisible();
await page.getByTestId("tab-all").click();
await expect(page.getByTestId("grid-all").locator(".MuiDataGrid-row").first()).toBeVisible();
});
test("ready to pay supports selection, create pay run stub, and pay-date edit", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("tab-ready").click();
const createPayRun = page.getByTestId("create-pay-run");
await expect(createPayRun).toBeDisabled();
const readyGrid = page.getByTestId("grid-ready");
await readyGrid.locator(".MuiDataGrid-row").first().getByRole("checkbox").click();
await expect(createPayRun).toBeEnabled();
await createPayRun.click();
await expect(page.getByTestId("create-pay-run-snackbar")).toContainText(
"Pay run stub: 1 invoice(s) selected",
);
await readyGrid.locator('input[aria-label="Select all rows"]').click();
await expect(createPayRun).toBeEnabled();
await createPayRun.click();
await expect(page.getByTestId("create-pay-run-snackbar")).toContainText(
/Pay run stub: [2-9]\d* invoice\(s\) selected/,
);
const payDateCell = readyGrid.locator('[data-field="payDate"]').nth(1);
await payDateCell.dblclick();
const editor = readyGrid
.locator('input[name="payDate"], .MuiDataGrid-editInputCell input')
.first();
await editor.fill("2026-09-01");
await editor.press("Enter");
await expect(payDateCell).toContainText("Sep 1, 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");
});
});

522
package-lock.json generated
View file

@ -21,21 +21,21 @@
"ky": "^1.10.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.8.2"
"react-router": "^8.3.0"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.55.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.34.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
@ -1084,118 +1084,89 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
"integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^3.1.5"
"minimatch": "^10.2.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0"
"@eslint/core": "^1.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
"integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.3.0",
"minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
"integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/object-schema": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
"integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@fontsource/ibm-plex-sans": {
@ -2184,9 +2155,9 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2198,9 +2169,12 @@
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"node": ">=22",
"npm": ">=6",
"yarn": ">=1"
},
"peerDependencies": {
"@testing-library/dom": ">=10 <11"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@ -2309,6 +2283,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2560,45 +2541,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@ -2654,19 +2596,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@ -2864,29 +2793,6 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@ -2923,11 +2829,14 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.13",
@ -2943,14 +2852,16 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/braces": {
@ -3056,23 +2967,6 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@ -3092,51 +2986,17 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
"node_modules/cookie-es": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
@ -3397,33 +3257,33 @@
}
},
"node_modules/eslint": {
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "9.39.5",
"@eslint/plugin-kit": "^0.4.1",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
@ -3433,8 +3293,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.5",
"minimatch": "^10.2.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@ -3442,7 +3301,7 @@
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
@ -3473,71 +3332,80 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=10"
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz",
"integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"eslint": ">=8.40"
"eslint": "^9 || ^10"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@ -3804,16 +3672,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -3826,6 +3684,23 @@
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -4012,29 +3887,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
@ -4185,13 +4037,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -4275,16 +4120,19 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
@ -4716,20 +4564,19 @@
}
},
"node_modules/react-router": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
"integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
"cookie-es": "^3.1.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.22.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
"react": ">=19.2.7",
"react-dom": ">=19.2.7"
},
"peerDependenciesMeta": {
"react-dom": {
@ -4924,12 +4771,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5005,19 +4846,6 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@ -5044,19 +4872,6 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@ -5732,6 +5547,29 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
}
}
}

View file

@ -34,21 +34,21 @@
"ky": "^1.10.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.8.2"
"react-router": "^8.3.0"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.55.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.34.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",

View file

@ -0,0 +1,36 @@
import { useState } from "react";
import { Button, Snackbar } from "@mui/material";
type CreatePayRunButtonProps = {
selectedCount: number;
};
export function CreatePayRunButton({ selectedCount }: CreatePayRunButtonProps) {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const handleClick = () => {
setMessage(`Pay run stub: ${selectedCount} invoice(s) selected`);
setOpen(true);
};
return (
<>
<Button
variant="contained"
disabled={selectedCount < 1}
onClick={handleClick}
data-testid="create-pay-run"
>
Create Pay Run
</Button>
<Snackbar
open={open}
autoHideDuration={4000}
onClose={() => setOpen(false)}
message={message}
data-testid="create-pay-run-snackbar"
/>
</>
);
}

View file

@ -0,0 +1,122 @@
import { useMemo, useState } from "react";
import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material";
import type { GridRowSelectionModel } from "@mui/x-data-grid";
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
import { allInvoicesColumns, pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
import { countSelectedRows } from "@/dashboard/selection";
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
import { areMocksEnabled, type MockInvoice } from "@/mocks";
type DashboardTab = "pending" | "ready" | "all";
const emptySelection: GridRowSelectionModel = {
type: "include",
ids: new Set(),
};
export function DashboardGrids() {
const [tab, setTab] = useState<DashboardTab>("pending");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const { data: invoices = [], isLoading, isError, error } = useInvoices();
const updatePayDate = useUpdateInvoicePayDate();
const pendingRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
[invoices],
);
const readyRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "approved"),
[invoices],
);
const selectedCount = countSelectedRows(
selection,
readyRows.map((invoice) => invoice.id),
);
const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => {
if (newRow.payDate === oldRow.payDate) {
return oldRow;
}
const nextPayDate = newRow.payDate?.trim() ? newRow.payDate.trim() : null;
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label="Loading invoices" />
</Box>
);
}
if (isError) {
return (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load invoices"}
</Alert>
);
}
return (
<Stack spacing={2}>
<Tabs
value={tab}
onChange={(_event, value: DashboardTab) => {
setTab(value);
setSelection(emptySelection);
}}
aria-label="Invoice dashboard tabs"
>
<Tab
label={`Pending Approval (${pendingRows.length})`}
value="pending"
data-testid="tab-pending"
/>
<Tab label={`Ready to Pay (${readyRows.length})`} value="ready" data-testid="tab-ready" />
<Tab label={`All Invoices (${invoices.length})`} value="all" data-testid="tab-all" />
</Tabs>
{tab === "pending" && (
<InvoiceDataGrid
data-testid="grid-pending"
rows={pendingRows}
columns={pendingApprovalColumns}
/>
)}
{tab === "ready" && (
<Stack spacing={1.5}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<CreatePayRunButton selectedCount={selectedCount} />
</Box>
<InvoiceDataGrid
data-testid="grid-ready"
rows={readyRows}
columns={readyToPayColumns}
checkboxSelection
rowSelectionModel={selection}
onRowSelectionModelChange={(model) => setSelection(model)}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={(processError) => {
console.error(processError);
}}
/>
<Typography variant="caption" color="text.secondary">
Double-click Pay Date to edit (ISO YYYY-MM-DD). Changes update mock state only.
</Typography>
</Stack>
)}
{tab === "all" && (
<InvoiceDataGrid data-testid="grid-all" rows={invoices} columns={allInvoicesColumns} />
)}
<Typography variant="body2" data-testid="mocks-status">
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
</Typography>
</Stack>
);
}

View file

@ -0,0 +1,31 @@
import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid";
type InvoiceDataGridProps<R extends GridValidRowModel> = DataGridProps<R> & {
"data-testid": string;
};
export function InvoiceDataGrid<R extends GridValidRowModel>({
"data-testid": testId,
...props
}: InvoiceDataGridProps<R>) {
return (
<div data-testid={testId}>
<DataGrid
autoHeight
disableColumnMenu
disableRowSelectionOnClick
pageSizeOptions={[10, 25]}
initialState={{
pagination: { paginationModel: { pageSize: 10, page: 0 } },
}}
getRowId={(row) => row.id as string}
sx={{
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
}}
{...props}
/>
</div>
);
}

82
src/dashboard/columns.tsx Normal file
View file

@ -0,0 +1,82 @@
import type { GridColDef } from "@mui/x-data-grid";
import type { MockInvoice } from "@/mocks";
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const dateFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
function formatIsoDate(value: string | null | undefined): string {
if (!value) {
return "—";
}
const parsed = new Date(`${value}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return dateFormatter.format(parsed);
}
function formatCurrency(value: number | null | undefined): string {
if (value == null) {
return "—";
}
return currencyFormatter.format(value);
}
const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef<MockInvoice> => ({
field,
headerName,
flex: 1,
minWidth: 120,
type: "number",
valueFormatter: (value: number | null | undefined) => formatCurrency(value),
});
const dateColumn = (
field: keyof MockInvoice,
headerName: string,
editable = false,
): GridColDef<MockInvoice> => ({
field,
headerName,
flex: 1,
minWidth: 130,
editable,
valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
});
export const pendingApprovalColumns: GridColDef<MockInvoice>[] = [
{ field: "invoiceNumbers", headerName: "Invoice #", flex: 1, minWidth: 120 },
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
dateColumn("dueDate", "Due Date"),
dateColumn("sendPaymentOn", "Send Payment On"),
{ field: "paymentMethod", headerName: "Payment Method", flex: 1, minWidth: 130 },
moneyColumn("amountDue", "Amount Due"),
moneyColumn("amount", "Amount"),
];
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
dateColumn("dueDate", "Due Date"),
dateColumn("payDate", "Pay Date", true),
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
{ field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 },
moneyColumn("amountDue", "Amount Due"),
];
export const allInvoicesColumns: GridColDef<MockInvoice>[] = [
{ field: "invoiceNumber", headerName: "Invoice #", flex: 1, minWidth: 120 },
{ field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
{ field: "status", headerName: "Status", flex: 1, minWidth: 140 },
dateColumn("dueDate", "Due Date"),
dateColumn("payDate", "Pay Date"),
{ field: "paymentMethod", headerName: "Payment Method", flex: 1, minWidth: 130 },
moneyColumn("amountDue", "Amount Due"),
moneyColumn("amount", "Amount"),
];

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { countSelectedRows } from "@/dashboard/selection";
describe("countSelectedRows", () => {
const rowIds = ["inv-1", "inv-2", "inv-3"];
it("counts include selections", () => {
expect(countSelectedRows({ type: "include", ids: new Set(["inv-1", "inv-3"]) }, rowIds)).toBe(
2,
);
});
it("counts exclude select-all with empty exclusions", () => {
expect(countSelectedRows({ type: "exclude", ids: new Set() }, rowIds)).toBe(3);
});
it("counts exclude models after deselecting one row", () => {
expect(countSelectedRows({ type: "exclude", ids: new Set(["inv-2"]) }, rowIds)).toBe(2);
});
it("returns zero for empty include selection", () => {
expect(countSelectedRows({ type: "include", ids: new Set() }, rowIds)).toBe(0);
});
});

View file

@ -0,0 +1,13 @@
import type { GridRowId, GridRowSelectionModel } from "@mui/x-data-grid";
/** Resolve selected row count for MUI DataGrid v8 include/exclude selection models. */
export function countSelectedRows(
selection: GridRowSelectionModel,
rowIds: readonly GridRowId[],
): number {
if (selection.type === "include") {
return rowIds.filter((id) => selection.ids.has(id)).length;
}
return rowIds.filter((id) => !selection.ids.has(id)).length;
}

View file

@ -0,0 +1,3 @@
export const invoiceQueryKeys = {
all: ["invoices"] as const,
};

View file

@ -0,0 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getMockInvoices, updateMockInvoicePayDate } from "@/mocks";
import { invoiceQueryKeys } from "@/domain/invoices/query-keys";
export function useInvoices() {
return useQuery({
queryKey: invoiceQueryKeys.all,
queryFn: getMockInvoices,
});
}
export function useUpdateInvoicePayDate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payDate }: { id: string; payDate: string | null }) =>
Promise.resolve(updateMockInvoicePayDate(id, payDate)),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: invoiceQueryKeys.all });
},
});
}

View file

@ -1,13 +1,50 @@
import { describe, expect, it } from "vitest";
import { areMocksEnabled, getMockInvoices, getMockVendors } from "@/mocks";
import { afterEach, describe, expect, it } from "vitest";
import {
areMocksEnabled,
getMockInvoices,
getMockVendors,
resetMockInvoices,
updateMockInvoicePayDate,
} from "@/mocks";
describe("mocks module", () => {
afterEach(() => {
resetMockInvoices();
});
it("defaults to mocks enabled", () => {
expect(areMocksEnabled()).toBe(true);
});
it("exposes sample invoices and vendors", () => {
expect(getMockVendors().length).toBeGreaterThan(0);
expect(getMockInvoices().length).toBeGreaterThan(0);
expect(getMockInvoices().length).toBeGreaterThanOrEqual(6);
});
it("seeds at least two invoices per dashboard tab", () => {
const invoices = getMockInvoices();
const pending = invoices.filter((invoice) => invoice.status === "pending_approval");
const ready = invoices.filter((invoice) => invoice.status === "approved");
expect(pending.length).toBeGreaterThanOrEqual(2);
expect(ready.length).toBeGreaterThanOrEqual(2);
expect(invoices.every((invoice) => invoice.invoiceNumbers && invoice.amount >= 0)).toBe(true);
});
it("updates pay date in the mutable mock store", () => {
const before = getMockInvoices().find((invoice) => invoice.id === "inv-1");
expect(before).toBeDefined();
const updated = updateMockInvoicePayDate("inv-1", "2026-08-18");
expect(updated.payDate).toBe("2026-08-18");
const after = getMockInvoices().find((invoice) => invoice.id === "inv-1");
expect(after?.payDate).toBe("2026-08-18");
});
it("throws when updating an unknown invoice", () => {
expect(() => updateMockInvoicePayDate("missing", "2026-08-18")).toThrow(
/Mock invoice not found/,
);
});
});

View file

@ -3,15 +3,20 @@ export type MockVendor = {
name: string;
};
export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid";
export type MockInvoice = {
id: string;
invoiceNumber: string;
invoiceNumbers: string;
vendorId: string;
vendorName: string;
amountDue: number;
amount: number;
dueDate: string;
payDate: string | null;
status: "pending_approval" | "approved" | "scheduled" | "paid";
sendPaymentOn: string | null;
status: MockInvoiceStatus;
paymentMethod: "check" | "ach";
};
@ -19,44 +24,113 @@ export const mockVendors: MockVendor[] = [
{ id: "vendor-1", name: "Acme Supplies Co" },
{ id: "vendor-2", name: "Harbor Maintenance LLC" },
{ id: "vendor-3", name: "Northwind Utilities" },
{ id: "vendor-4", name: "Coastal Office Supply" },
];
export const mockInvoices: MockInvoice[] = [
const seedInvoices: MockInvoice[] = [
{
id: "inv-1",
invoiceNumber: "INV-1001",
invoiceNumbers: "INV-1001",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 1250.5,
amount: 1250.5,
dueDate: "2026-08-20",
payDate: "2026-08-15",
sendPaymentOn: "2026-08-15",
status: "approved",
paymentMethod: "check",
},
{
id: "inv-2",
invoiceNumber: "INV-1002",
invoiceNumbers: "INV-1002",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 4800,
amount: 4800,
dueDate: "2026-08-25",
payDate: null,
sendPaymentOn: "2026-08-20",
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-3",
invoiceNumber: "INV-1003",
invoiceNumbers: "INV-1003",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 312.75,
amount: 312.75,
dueDate: "2026-08-12",
payDate: "2026-08-10",
sendPaymentOn: "2026-08-10",
status: "approved",
paymentMethod: "check",
},
{
id: "inv-4",
invoiceNumber: "INV-1004",
invoiceNumbers: "INV-1004",
vendorId: "vendor-4",
vendorName: "Coastal Office Supply",
amountDue: 890.25,
amount: 900,
dueDate: "2026-09-01",
payDate: null,
sendPaymentOn: "2026-08-27",
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-5",
invoiceNumber: "INV-1005",
invoiceNumbers: "INV-1005",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 2100,
amount: 2100,
dueDate: "2026-07-30",
payDate: "2026-07-25",
sendPaymentOn: "2026-07-25",
status: "scheduled",
paymentMethod: "check",
},
{
id: "inv-6",
invoiceNumber: "INV-1006",
invoiceNumbers: "INV-1006",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 0,
amount: 1550,
dueDate: "2026-07-15",
payDate: "2026-07-10",
sendPaymentOn: "2026-07-10",
status: "paid",
paymentMethod: "ach",
},
{
id: "inv-7",
invoiceNumber: "INV-1007",
invoiceNumbers: "INV-1007",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 645.1,
amount: 645.1,
dueDate: "2026-08-28",
payDate: "2026-08-22",
sendPaymentOn: "2026-08-22",
status: "approved",
paymentMethod: "ach",
},
];
/** Mutable in-memory invoice store for mock POC interactions. */
let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => ({ ...invoice }));
/** Mocks are the default data path until the API is wired. */
export function areMocksEnabled(): boolean {
return import.meta.env.VITE_USE_MOCKS !== "false";
@ -66,7 +140,7 @@ export function getMockInvoices(): MockInvoice[] {
if (!areMocksEnabled()) {
return [];
}
return mockInvoices;
return invoiceStore.map((invoice) => ({ ...invoice }));
}
export function getMockVendors(): MockVendor[] {
@ -75,3 +149,19 @@ export function getMockVendors(): MockVendor[] {
}
return mockVendors;
}
export function updateMockInvoicePayDate(id: string, payDate: string | null): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
throw new Error(`Mock invoice not found: ${id}`);
}
const updated: MockInvoice = { ...invoiceStore[index], payDate };
invoiceStore = [...invoiceStore.slice(0, index), updated, ...invoiceStore.slice(index + 1)];
return { ...updated };
}
/** Test helper to restore seed data between unit tests. */
export function resetMockInvoices(): void {
invoiceStore = seedInvoices.map((invoice) => ({ ...invoice }));
}

View file

@ -1,24 +1,18 @@
import { Box, Container, Stack, Typography } from "@mui/material";
import { areMocksEnabled, getMockInvoices, getMockVendors } from "@/mocks";
import { DashboardGrids } from "@/dashboard/DashboardGrids";
export default function HomePage() {
const invoices = getMockInvoices();
const vendors = getMockVendors();
return (
<Box component="main" sx={{ minHeight: "100vh", py: 8 }}>
<Container maxWidth="md">
<Stack spacing={2}>
<Typography component="h1" variant="h1">
Sea Haven AP
</Typography>
<Typography color="text.secondary">
Internal accounts payable automation. Dashboard grids land in AP-5.
</Typography>
<Typography variant="body2" data-testid="mocks-status">
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {vendors.length} vendors,{" "}
{invoices.length} invoices
</Typography>
<Box component="main" sx={{ minHeight: "100vh", py: 4 }}>
<Container maxWidth="xl">
<Stack spacing={3}>
<Stack spacing={1}>
<Typography component="h1" variant="h1">
Sea Haven AP
</Typography>
<Typography color="text.secondary">Internal accounts payable automation.</Typography>
</Stack>
<DashboardGrids />
</Stack>
</Container>
</Box>