) {
+ return (
+
+ row.id as string}
+ sx={{
+ border: 1,
+ borderColor: "divider",
+ backgroundColor: "background.paper",
+ }}
+ {...props}
+ />
+
+ );
+}
diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx
new file mode 100644
index 0000000..2797681
--- /dev/null
+++ b/src/dashboard/columns.tsx
@@ -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 => ({
+ 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 => ({
+ field,
+ headerName,
+ flex: 1,
+ minWidth: 130,
+ editable,
+ valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
+});
+
+export const pendingApprovalColumns: GridColDef[] = [
+ { 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[] = [
+ 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[] = [
+ { 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"),
+];
diff --git a/src/dashboard/selection.test.ts b/src/dashboard/selection.test.ts
new file mode 100644
index 0000000..9982288
--- /dev/null
+++ b/src/dashboard/selection.test.ts
@@ -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);
+ });
+});
diff --git a/src/dashboard/selection.ts b/src/dashboard/selection.ts
new file mode 100644
index 0000000..80e78b1
--- /dev/null
+++ b/src/dashboard/selection.ts
@@ -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;
+}
diff --git a/src/domain/invoices/query-keys.ts b/src/domain/invoices/query-keys.ts
new file mode 100644
index 0000000..7311c5d
--- /dev/null
+++ b/src/domain/invoices/query-keys.ts
@@ -0,0 +1,3 @@
+export const invoiceQueryKeys = {
+ all: ["invoices"] as const,
+};
diff --git a/src/domain/invoices/use-invoices.ts b/src/domain/invoices/use-invoices.ts
new file mode 100644
index 0000000..b77d202
--- /dev/null
+++ b/src/domain/invoices/use-invoices.ts
@@ -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 });
+ },
+ });
+}
diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts
index c01f458..8ba47bb 100644
--- a/src/mocks/index.test.ts
+++ b/src/mocks/index.test.ts
@@ -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/,
+ );
});
});
diff --git a/src/mocks/index.ts b/src/mocks/index.ts
index acee3f5..b432420 100644
--- a/src/mocks/index.ts
+++ b/src/mocks/index.ts
@@ -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 }));
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index af6d29c..fb1644a 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -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 (
-
-
-
-
- Sea Haven AP
-
-
- Internal accounts payable automation. Dashboard grids land in AP-5.
-
-
- Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {vendors.length} vendors,{" "}
- {invoices.length} invoices
-
+
+
+
+
+
+ Sea Haven AP
+
+ Internal accounts payable automation.
+
+