mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-02 09:43:24 +00:00
feat(web): add Action and Vendor filters to invoice processing
Refs: AP-43
This commit is contained in:
parent
bf555a346e
commit
0ded294835
4 changed files with 199 additions and 26 deletions
19
e2e/invoice-filters.spec.ts
Normal file
19
e2e/invoice-filters.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
112
src/dashboard/InvoiceFilterToolbar.tsx
Normal file
112
src/dashboard/InvoiceFilterToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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: "" };
|
||||
Loading…
Add table
Reference in a new issue