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
This commit is contained in:
Adam Moussa 2026-09-25 22:13:00 +00:00 • committed by GitHub
parent c6867e6bec
commit fad8461274
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 328 additions and 56 deletions

View file

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

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,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<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,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}
>
<Tab
label={`For My Attention (${attentionRows.length})`}
label={
<InvoiceTabLabel
title="For My Attention"
count={filteredAttention.length}
active={tab === "attention"}
/>
}
value="attention"
data-testid="tab-pending"
/>
<Tab label={`New Invoices (${invoices.length})`} value="all" data-testid="tab-all" />
<Tab
label={`Approved Invoices (${approvedRows.length})`}
label={
<InvoiceTabLabel
title="New Invoices"
count={filteredAll.length}
active={tab === "all"}
/>
}
value="all"
data-testid="tab-all"
/>
<Tab
label={
<InvoiceTabLabel
title="Approved Invoices"
count={filteredApproved.length}
active={tab === "approved"}
/>
}
value="approved"
data-testid="tab-ready"
/>
<Tab
label={`Rejected Invoices (${rejectedRows.length})`}
label={
<InvoiceTabLabel
title="Rejected Invoices"
count={filteredRejected.length}
active={tab === "rejected"}
/>
}
value="rejected"
data-testid="tab-rejected"
/>
<Tab label={`Late Invoices (${lateRows.length})`} value="late" data-testid="tab-late" />
<Tab
label={
<InvoiceTabLabel
title="Late Invoices"
count={filteredLate.length}
active={tab === "late"}
urgency
/>
}
value="late"
data-testid="tab-late"
/>
</Tabs>
</Box>
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
{attentionRows.length > 0 && tab === "attention" ? (
<Alert severity="info" sx={{ mb: 2 }} data-testid="attention-banner">
{attentionRows.length} invoices require approval. Show Invoices ready to approve.
</Alert>
{filteredAttention.length > 0 && tab === "attention" ? (
<Typography
data-testid="attention-banner"
variant="body2"
sx={{ display: "block", mb: 1.5, color: "primary.main", textAlign: "left" }}
>
{filteredAttention.length} invoices require approval.{" "}
<Box component="span" sx={{ textDecoration: "underline" }}>
Show invoices ready to approve.
</Box>
</Typography>
) : null}
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
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}
@ -182,7 +254,7 @@ export function DashboardGrids() {
{tab === "all" && (
<InvoiceDataGrid
data-testid="grid-all"
rows={invoices}
rows={filteredAll}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
@ -192,7 +264,7 @@ export function DashboardGrids() {
{tab === "approved" && (
<InvoiceDataGrid
data-testid="grid-ready"
rows={approvedRows}
rows={filteredApproved}
columns={readyToPayColumns}
checkboxSelection
rowSelectionModel={selection}
@ -208,7 +280,7 @@ export function DashboardGrids() {
{tab === "rejected" && (
<InvoiceDataGrid
data-testid="grid-rejected"
rows={rejectedRows}
rows={filteredRejected}
columns={allInvoicesColumns}
onRowClick={openInvoice}
sx={rowClickSx}
@ -218,7 +290,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,117 @@
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;
};
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 (
<Stack spacing={1.5} sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} useFlexGap sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Box data-testid="action-filter" sx={{ ...fieldSx, minWidth: 220 }}>
<Autocomplete
size="small"
options={[...INVOICE_ACTION_OPTIONS]}
value={filters.action}
onChange={(_event, value) => onFiltersChange({ ...filters, action: value })}
renderInput={(params) => <TextField {...params} placeholder="Search" />}
/>
</Box>
<TextField
size="small"
name="vendor"
id="vendor"
placeholder="Vendor"
value={filters.vendorQuery}
onChange={(event) => onFiltersChange({ ...filters, vendorQuery: event.target.value })}
sx={fieldSx}
slotProps={{
htmlInput: { "data-testid": "vendor-filter", "aria-label": "Vendor" },
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>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button variant="contained" size="small" data-testid="review-all-items">
Review All Items
</Button>
{trailingActions}
</Stack>
</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: "" };

View file

@ -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 (
<Stack direction="row" spacing={0.75} component="span" sx={{ alignItems: "center" }}>
<Box component="span">{title}</Box>
{urgency ? (
<ErrorOutlinedIcon sx={{ fontSize: 16, color: "error.main" }} aria-hidden />
) : null}
<Box
component="span"
sx={{
minWidth: 22,
height: 22,
px: 0.75,
borderRadius: 999,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 700,
bgcolor: active ? "#7B61FF" : urgency ? "error.main" : "primary.main",
color: "#fff",
}}
>
{count}
</Box>
</Stack>
);
}