diff --git a/e2e/invoice-filters.spec.ts b/e2e/invoice-filters.spec.ts new file mode 100644 index 0000000..49f1db1 --- /dev/null +++ b/e2e/invoice-filters.spec.ts @@ -0,0 +1,19 @@ +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 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); + }); +}); diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index e6b97f9..ba23661 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,7 @@ import { invoiceAttentionColumns, readyToPayColumns, } from "@/dashboard/columns"; +import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; import { countSelectedRows } from "@/dashboard/selection"; import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices"; import { areMocksEnabled, type MockInvoice } from "@/mocks"; @@ -30,6 +22,8 @@ const emptySelection: GridRowSelectionModel = { ids: new Set(), }; +const emptyFilters = EMPTY_INVOICE_FILTERS; + const rowClickSx = { border: 1, borderColor: "divider", @@ -38,10 +32,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,9 +79,24 @@ 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), + filteredApproved.map((invoice) => invoice.id), ); const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => { @@ -152,20 +177,20 @@ export function DashboardGrids() { ) : null} - - - - - - {tab === "approved" ? : null} - + setFilterDrawerOpen(true)} + onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)} + trailingActions={ + tab === "approved" ? : null + } + /> {tab === "attention" && ( void; + onOpenFilterDrawer: () => void; + onOpenColumnsDrawer: () => void; + trailingActions?: ReactElement | null; +}; + +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={{ minWidth: 180 }} + slotProps={{ + htmlInput: { "data-testid": "vendor-filter" }, + 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: "" };