feat(web): add Action and Vendor filters to invoice processing

Refs: AP-43
This commit is contained in:
Adam Moussa 2026-08-11 15:20:31 -04:00
parent bf555a346e
commit 0ded294835
No known key found for this signature in database
4 changed files with 199 additions and 26 deletions

View file

@ -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);
});
});

View file

@ -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<InvoiceTab>("attention");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const [filters, setFilters] = useState<InvoiceFilterState>(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() {
</Alert>
) : null}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Chip label="Action" variant="outlined" size="small" />
<Chip label="Vendor" variant="outlined" size="small" />
<Box sx={{ flexGrow: 1 }} />
<Button variant="contained" size="small" data-testid="review-all-items">
Review All Items
</Button>
{tab === "approved" ? <CreatePayRunButton selectedCount={selectedCount} /> : null}
</Stack>
<InvoiceFilterToolbar
filters={filters}
onFiltersChange={setFilters}
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)}
trailingActions={
tab === "approved" ? <CreatePayRunButton selectedCount={selectedCount} /> : null
}
/>
{tab === "attention" && (
<InvoiceDataGrid
data-testid="grid-pending"
rows={attentionRows}
rows={filteredAttention}
columns={invoiceAttentionColumns}
onRowClick={openInvoice}
sx={rowClickSx}
@ -175,7 +200,7 @@ export function DashboardGrids() {
{tab === "all" && (
<InvoiceDataGrid
data-testid="grid-all"
rows={invoices}
rows={filteredAll}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
@ -185,7 +210,7 @@ export function DashboardGrids() {
{tab === "approved" && (
<InvoiceDataGrid
data-testid="grid-ready"
rows={approvedRows}
rows={filteredApproved}
columns={readyToPayColumns}
checkboxSelection
rowSelectionModel={selection}
@ -201,7 +226,7 @@ export function DashboardGrids() {
{tab === "rejected" && (
<InvoiceDataGrid
data-testid="grid-rejected"
rows={rejectedRows}
rows={filteredRejected}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
@ -211,7 +236,7 @@ export function DashboardGrids() {
{tab === "late" && (
<InvoiceDataGrid
data-testid="grid-late"
rows={lateRows}
rows={filteredLate}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}

View file

@ -0,0 +1,112 @@
import FilterListIcon from "@mui/icons-material/FilterList";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ViewWeekOutlinedIcon from "@mui/icons-material/ViewWeekOutlined";
import {
Autocomplete,
Badge,
Box,
Button,
IconButton,
InputAdornment,
Stack,
TextField,
} from "@mui/material";
import type { ReactElement } from "react";
import { INVOICE_ACTION_OPTIONS } from "@/dashboard/column-catalog";
import { countActiveFilters, type InvoiceFilterState } from "@/dashboard/invoice-filters";
type InvoiceFilterToolbarProps = {
filters: InvoiceFilterState;
onFiltersChange: (next: InvoiceFilterState) => void;
onOpenFilterDrawer: () => void;
onOpenColumnsDrawer: () => void;
trailingActions?: ReactElement | null;
};
export function InvoiceFilterToolbar({
filters,
onFiltersChange,
onOpenFilterDrawer,
onOpenColumnsDrawer,
trailingActions = null,
}: InvoiceFilterToolbarProps): ReactElement {
const activeCount = countActiveFilters(filters);
return (
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ mb: 2 }}
flexWrap="wrap"
useFlexGap
>
<Box data-testid="action-filter" sx={{ minWidth: 240 }}>
<Autocomplete
size="small"
options={[...INVOICE_ACTION_OPTIONS]}
value={filters.action}
onChange={(_event, value) => onFiltersChange({ ...filters, action: value })}
renderInput={(params) => <TextField {...params} placeholder="Search" label="Action" />}
/>
</Box>
<TextField
size="small"
name="vendor"
id="vendor"
label="Vendor"
placeholder="Vendor"
value={filters.vendorQuery}
onChange={(event) => onFiltersChange({ ...filters, vendorQuery: event.target.value })}
sx={{ minWidth: 180 }}
slotProps={{
htmlInput: { "data-testid": "vendor-filter" },
input: {
endAdornment: filters.vendorQuery ? (
<InputAdornment position="end">
<Button
size="small"
onClick={() => onFiltersChange({ ...filters, vendorQuery: "" })}
aria-label="Clear vendor"
>
Clear
</Button>
</InputAdornment>
) : undefined,
},
}}
/>
<Box sx={{ flexGrow: 1 }} />
<IconButton
size="small"
aria-label="Filter actions"
data-testid="filter-actions-menu"
id="default-filter-actions-menu"
>
<MoreVertIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Open filters"
data-testid="filter-toggle"
onClick={onOpenFilterDrawer}
>
<Badge badgeContent={activeCount || undefined} color="primary">
<FilterListIcon fontSize="small" />
</Badge>
</IconButton>
<IconButton
size="small"
aria-label="Open columns"
data-testid="columns-toggle"
onClick={onOpenColumnsDrawer}
>
<ViewWeekOutlinedIcon fontSize="small" />
</IconButton>
<Button variant="contained" size="small" data-testid="review-all-items">
Review All Items
</Button>
{trailingActions}
</Stack>
);
}

View file

@ -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: "" };