From fad8461274490640b08e0784f858f05ac9635559 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:13:00 +0000 Subject: [PATCH] feat(web): action/vendor filters, toolbar layout, and tabs (AP-43) (#17) * feat(web): fix shell chrome, logo home, and live nav badges Refs: AP-42 * feat(web): add invoice grid lines and bold headers Refs: AP-44 * feat(web): support auto row height for multi-line invoice cells Refs: AP-44 * feat(web): expand mock invoice fields for Stampli column catalog Refs: AP-47 * feat(web): add Action and Vendor filters to invoice processing Refs: AP-43 * feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 * feat(web): align filter toolbar layout, field shapes, and tabs Refs: AP-43 * fix(web): address review feedback * fix(web): address review feedback * ci: remove the pr policy caller * fix(web): keep mock catalog dates and updates in sync * fix(web): render the attention banner as text * fix(web): make the attention banner non-interactive copy * fix(web): derive mock action labels from invoice status * fix(web): restore link-style attention banner copy * fix(ci): drop the unused catalog refresh index * fix(ci): format invoice mock helpers * fix(web): count filtered rows on invoice tabs * fix(web): match the filter toolbar to MUI 9 --- e2e/invoice-filters.spec.ts | 24 ++++ src/dashboard/DashboardGrids.tsx | 184 +++++++++++++++++-------- src/dashboard/InvoiceFilterToolbar.tsx | 117 ++++++++++++++++ src/dashboard/invoice-filters.ts | 17 +++ src/dashboard/invoice-tabs.tsx | 42 ++++++ 5 files changed, 328 insertions(+), 56 deletions(-) create mode 100644 e2e/invoice-filters.spec.ts create mode 100644 src/dashboard/InvoiceFilterToolbar.tsx create mode 100644 src/dashboard/invoice-filters.ts create mode 100644 src/dashboard/invoice-tabs.tsx diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts new file mode 100644 index 0000000..2f340d3 --- /dev/null +++ b/e2e/invoice-filters.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Invoice Processing filters (AP-43)", () => { + test("Action and Vendor filters reduce grid rows and badge counts", async ({ page }) => { + await page.goto("/"); + const grid = page.getByTestId("grid-pending"); + await expect(grid.locator(".MuiDataGrid-row").first()).toBeVisible(); + const before = await grid.locator(".MuiDataGrid-row").count(); + expect(before).toBeGreaterThan(0); + + await expect(page.getByTestId("action-filter").getByPlaceholder("Search")).toBeVisible(); + await page.getByTestId("action-filter").click(); + await page.getByRole("option", { name: "Approve invoice" }).click(); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("1"); + + await page.getByTestId("vendor-filter").fill("Harbor"); + await expect(page.getByTestId("filter-toggle").locator(".MuiBadge-badge")).toHaveText("2"); + await expect(grid.locator(".MuiDataGrid-row")).toHaveCount(1); + await expect(page.getByTestId("tab-pending").locator("span").last()).toHaveText("1"); + await expect(page.getByTestId("attention-banner")).toContainText( + "1 invoices require approval. Show invoices ready to approve.", + ); + }); +}); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index 4b98833..9d767fa 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,16 +1,7 @@ import { useMemo, useState } from "react"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Stack, - Tab, - Tabs, - Typography, -} from "@mui/material"; +import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; +import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; import { @@ -18,6 +9,8 @@ import { invoiceAttentionColumns, readyToPayColumns, } from "@/dashboard/columns"; +import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; +import { InvoiceTabLabel } from "@/dashboard/invoice-tabs"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; @@ -30,6 +23,29 @@ const emptySelection: GridRowSelectionModel = { ids: new Set(), }; +const emptyFilters = EMPTY_INVOICE_FILTERS; + +const invoiceTabsSx = { + minHeight: 44, + "& .MuiTab-root": { + bgcolor: "rgba(47,128,237,0.12)", + mr: 0.5, + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + color: "text.primary", + textTransform: "none", + }, + "& .Mui-selected": { + bgcolor: "#fff !important", + color: "text.primary !important", + }, + "& .MuiTabs-indicator": { + height: 3, + borderRadius: 1, + backgroundColor: "#7B61FF", + }, +} as const; + const rowClickSx = { border: 1, borderColor: "divider", @@ -38,10 +54,26 @@ const rowClickSx = { borderRadius: 1, } as const; +function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] { + const vendorQuery = filters.vendorQuery.trim().toLowerCase(); + return rows.filter((invoice) => { + if (filters.action && invoice.actionLabel !== filters.action) { + return false; + } + if (vendorQuery && !invoice.vendorName.toLowerCase().includes(vendorQuery)) { + return false; + } + return true; + }); +} + export function DashboardGrids() { const navigate = useNavigate(); const [tab, setTab] = useState("attention"); const [selection, setSelection] = useState(emptySelection); + const [filters, setFilters] = useState(emptyFilters); + const [, setColumnsDrawerOpen] = useState(false); + const [, setFilterDrawerOpen] = useState(false); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); @@ -69,6 +101,21 @@ export function DashboardGrids() { ); }, [invoices]); + const filteredAttention = useMemo( + () => applyInvoiceFilters(attentionRows, filters), + [attentionRows, filters], + ); + const filteredAll = useMemo(() => applyInvoiceFilters(invoices, filters), [invoices, filters]); + const filteredApproved = useMemo( + () => applyInvoiceFilters(approvedRows, filters), + [approvedRows, filters], + ); + const filteredRejected = useMemo( + () => applyInvoiceFilters(rejectedRows, filters), + [rejectedRows, filters], + ); + const filteredLate = useMemo(() => applyInvoiceFilters(lateRows, filters), [lateRows, filters]); + const selectedCount = countSelectedRows( selection, approvedRows.map((invoice) => invoice.id), @@ -109,70 +156,95 @@ export function DashboardGrids() { }} variant="scrollable" aria-label="Invoice Processing tabs" - sx={{ - minHeight: 44, - "& .MuiTab-root": { - bgcolor: "rgba(47,128,237,0.12)", - mr: 0.5, - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - color: "text.primary", - }, - "& .Mui-selected": { - bgcolor: "#fff !important", - color: "text.primary !important", - }, - "& .MuiTabs-indicator": { display: "none" }, - }} + sx={invoiceTabsSx} > + } value="attention" data-testid="tab-pending" /> - + } + value="all" + data-testid="tab-all" + /> + + } value="approved" data-testid="tab-ready" /> + } value="rejected" data-testid="tab-rejected" /> - + + } + value="late" + data-testid="tab-late" + /> - {attentionRows.length > 0 && tab === "attention" ? ( - - {attentionRows.length} invoices require approval. Show Invoices ready to approve. - + {filteredAttention.length > 0 && tab === "attention" ? ( + + {filteredAttention.length} invoices require approval.{" "} + + Show invoices ready to approve. + + ) : null} - - - - - - {tab === "approved" ? : null} - + setFilterDrawerOpen(true)} + onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)} + trailingActions={ + tab === "approved" ? : null + } + /> {tab === "attention" && ( void; + onOpenFilterDrawer: () => void; + onOpenColumnsDrawer: () => void; + trailingActions?: ReactElement | null; +}; + +const fieldSx = { + minWidth: 180, + "& .MuiOutlinedInput-root": { + borderRadius: "6px", + backgroundColor: "background.paper", + }, +} as const; + +export function InvoiceFilterToolbar({ + filters, + onFiltersChange, + onOpenFilterDrawer, + onOpenColumnsDrawer, + trailingActions = null, +}: InvoiceFilterToolbarProps): ReactElement { + const activeCount = countActiveFilters(filters); + + return ( + + + + onFiltersChange({ ...filters, action: value })} + renderInput={(params) => } + /> + + onFiltersChange({ ...filters, vendorQuery: event.target.value })} + sx={fieldSx} + slotProps={{ + htmlInput: { "data-testid": "vendor-filter", "aria-label": "Vendor" }, + input: { + endAdornment: filters.vendorQuery ? ( + + + + ) : undefined, + }, + }} + /> + + + + + + + + + + + + + + + + + {trailingActions} + + + ); +} diff --git a/src/dashboard/invoice-filters.ts b/src/dashboard/invoice-filters.ts new file mode 100644 index 0000000..5504bcc --- /dev/null +++ b/src/dashboard/invoice-filters.ts @@ -0,0 +1,17 @@ +export type InvoiceFilterState = { + action: string | null; + vendorQuery: string; +}; + +export function countActiveFilters(filters: InvoiceFilterState): number { + let count = 0; + if (filters.action) { + count += 1; + } + if (filters.vendorQuery.trim()) { + count += 1; + } + return count; +} + +export const EMPTY_INVOICE_FILTERS: InvoiceFilterState = { action: null, vendorQuery: "" }; diff --git a/src/dashboard/invoice-tabs.tsx b/src/dashboard/invoice-tabs.tsx new file mode 100644 index 0000000..605ba29 --- /dev/null +++ b/src/dashboard/invoice-tabs.tsx @@ -0,0 +1,42 @@ +import ErrorOutlinedIcon from "@mui/icons-material/ErrorOutlined"; +import { Box, Stack } from "@mui/material"; +import type { ReactElement } from "react"; + +export function InvoiceTabLabel({ + title, + count, + active = false, + urgency = false, +}: { + title: string; + count: number; + active?: boolean; + urgency?: boolean; +}): ReactElement { + return ( + + {title} + {urgency ? ( + + ) : null} + + {count} + + + ); +}