From 28a91cb7a020795812510d5c8a15502cee116de2 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:32:43 -0400 Subject: [PATCH] feat(frontend): add dashboard grids on mocks (AP-5) (#7) --- e2e/dashboard-grids.spec.ts | 62 ++++++++++++++ src/dashboard/CreatePayRunButton.tsx | 36 ++++++++ src/dashboard/DashboardGrids.tsx | 122 +++++++++++++++++++++++++++ src/dashboard/InvoiceDataGrid.tsx | 31 +++++++ src/dashboard/columns.tsx | 82 ++++++++++++++++++ src/dashboard/selection.test.ts | 24 ++++++ src/dashboard/selection.ts | 13 +++ src/domain/invoices/query-keys.ts | 3 + src/domain/invoices/use-invoices.ts | 22 +++++ src/mocks/index.test.ts | 43 +++++++++- src/mocks/index.ts | 96 ++++++++++++++++++++- src/pages/index.tsx | 28 +++--- 12 files changed, 539 insertions(+), 23 deletions(-) create mode 100644 e2e/dashboard-grids.spec.ts create mode 100644 src/dashboard/CreatePayRunButton.tsx create mode 100644 src/dashboard/DashboardGrids.tsx create mode 100644 src/dashboard/InvoiceDataGrid.tsx create mode 100644 src/dashboard/columns.tsx create mode 100644 src/dashboard/selection.test.ts create mode 100644 src/dashboard/selection.ts create mode 100644 src/domain/invoices/query-keys.ts create mode 100644 src/domain/invoices/use-invoices.ts diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts new file mode 100644 index 0000000..3991192 --- /dev/null +++ b/e2e/dashboard-grids.spec.ts @@ -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"); + }); +}); diff --git a/src/dashboard/CreatePayRunButton.tsx b/src/dashboard/CreatePayRunButton.tsx new file mode 100644 index 0000000..a7c1ab7 --- /dev/null +++ b/src/dashboard/CreatePayRunButton.tsx @@ -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 ( + <> + + setOpen(false)} + message={message} + data-testid="create-pay-run-snackbar" + /> + + ); +} diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx new file mode 100644 index 0000000..a120d5a --- /dev/null +++ b/src/dashboard/DashboardGrids.tsx @@ -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("pending"); + const [selection, setSelection] = useState(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 ( + + + + ); + } + + if (isError) { + return ( + + {error instanceof Error ? error.message : "Failed to load invoices"} + + ); + } + + return ( + + { + setTab(value); + setSelection(emptySelection); + }} + aria-label="Invoice dashboard tabs" + > + + + + + + {tab === "pending" && ( + + )} + + {tab === "ready" && ( + + + + + setSelection(model)} + processRowUpdate={processRowUpdate} + onProcessRowUpdateError={(processError) => { + console.error(processError); + }} + /> + + Double-click Pay Date to edit (ISO YYYY-MM-DD). Changes update mock state only. + + + )} + + {tab === "all" && ( + + )} + + + Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices + + + ); +} diff --git a/src/dashboard/InvoiceDataGrid.tsx b/src/dashboard/InvoiceDataGrid.tsx new file mode 100644 index 0000000..81f2359 --- /dev/null +++ b/src/dashboard/InvoiceDataGrid.tsx @@ -0,0 +1,31 @@ +import { DataGrid, type DataGridProps, type GridValidRowModel } from "@mui/x-data-grid"; + +type InvoiceDataGridProps = DataGridProps & { + "data-testid": string; +}; + +export function InvoiceDataGrid({ + "data-testid": testId, + ...props +}: InvoiceDataGridProps) { + 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. + +