feat(frontend): add dashboard grids on mocks (AP-5) (#7)

This commit is contained in:
Adam Moussa 2026-08-10 17:32:43 -04:00 • committed by GitHub
parent 0136cfe5ad
commit 28a91cb7a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 539 additions and 23 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");
});
});

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>