mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 03:23:16 +00:00
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:
parent
c6867e6bec
commit
fad8461274
5 changed files with 328 additions and 56 deletions
24
e2e/invoice-filters.spec.ts
Normal file
24
e2e/invoice-filters.spec.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
117
src/dashboard/InvoiceFilterToolbar.tsx
Normal file
117
src/dashboard/InvoiceFilterToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/dashboard/invoice-filters.ts
Normal file
17
src/dashboard/invoice-filters.ts
Normal 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: "" };
|
||||
42
src/dashboard/invoice-tabs.tsx
Normal file
42
src/dashboard/invoice-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue