mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-07 00:01:57 +00:00
feat(web): add Columns slide-over with Stampli HTML defaults
Refs: AP-45
This commit is contained in:
parent
36d9fcdc6c
commit
5a02da2e96
7 changed files with 438 additions and 17 deletions
30
e2e/invoice-columns.spec.ts
Normal file
30
e2e/invoice-columns.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Invoice Processing columns drawer (AP-45)", () => {
|
||||||
|
test("Columns drawer can hide Tagging and Reset restores it", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("columns-toggle").click();
|
||||||
|
const drawer = page.getByTestId("columns-drawer");
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
|
||||||
|
const taggingRow = drawer.getByTestId("columns-visible-tagging");
|
||||||
|
await taggingRow.scrollIntoViewIfNeeded();
|
||||||
|
await taggingRow.locator('input[type="checkbox"]').evaluate((el: HTMLInputElement) => {
|
||||||
|
el.click();
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => {
|
||||||
|
el.click();
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("grid-pending").locator('.MuiDataGrid-columnHeader[data-field="tagging"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
56
package-lock.json
generated
56
package-lock.json
generated
|
|
@ -11,6 +11,9 @@
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
|
|
@ -756,6 +759,59 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@
|
||||||
},
|
},
|
||||||
"packageManager": "npm@11.16.0",
|
"packageManager": "npm@11.16.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
|
|
|
||||||
199
src/dashboard/ColumnsDrawer.tsx
Normal file
199
src/dashboard/ColumnsDrawer.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Drawer,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { CSSProperties, ReactElement } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { InvoiceColumnKey } from "@/dashboard/column-catalog";
|
||||||
|
import {
|
||||||
|
catalogLabel,
|
||||||
|
hiddenColumnKeys,
|
||||||
|
isColumnLocked,
|
||||||
|
type ColumnPrefs,
|
||||||
|
} from "@/dashboard/column-prefs";
|
||||||
|
|
||||||
|
type ColumnsDrawerProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
prefs: ColumnPrefs;
|
||||||
|
onPrefsChange: (next: ColumnPrefs) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortableVisibleRow({
|
||||||
|
columnKey,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
columnKey: InvoiceColumnKey;
|
||||||
|
onToggle: (key: InvoiceColumnKey, checked: boolean) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const locked = isColumnLocked(columnKey);
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||||
|
id: columnKey,
|
||||||
|
});
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={setNodeRef} style={style} data-testid={`columns-visible-${columnKey}`}>
|
||||||
|
<IconButton size="small" {...attributes} {...listeners} aria-label={`Reorder ${columnKey}`}>
|
||||||
|
<DragIndicatorIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(event) => onToggle(columnKey, event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={catalogLabel(columnKey)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnsDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
prefs,
|
||||||
|
onPrefsChange,
|
||||||
|
onReset,
|
||||||
|
}: ColumnsDrawerProps): ReactElement {
|
||||||
|
const [hiddenQuery, setHiddenQuery] = useState("");
|
||||||
|
const [viewAllHidden, setViewAllHidden] = useState(false);
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||||
|
|
||||||
|
const hidden = useMemo(() => hiddenColumnKeys(prefs.visibleOrder), [prefs.visibleOrder]);
|
||||||
|
const filteredHidden = useMemo(() => {
|
||||||
|
const q = hiddenQuery.trim().toLowerCase();
|
||||||
|
const list = q ? hidden.filter((key) => catalogLabel(key).toLowerCase().includes(q)) : hidden;
|
||||||
|
if (viewAllHidden || q) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
return list.slice(0, 5);
|
||||||
|
}, [hidden, hiddenQuery, viewAllHidden]);
|
||||||
|
|
||||||
|
const onDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldIndex = prefs.visibleOrder.indexOf(active.id as InvoiceColumnKey);
|
||||||
|
const newIndex = prefs.visibleOrder.indexOf(over.id as InvoiceColumnKey);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPrefsChange({ visibleOrder: arrayMove(prefs.visibleOrder, oldIndex, newIndex) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggle = (key: InvoiceColumnKey, checked: boolean) => {
|
||||||
|
if (isColumnLocked(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checked) {
|
||||||
|
if (prefs.visibleOrder.includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPrefsChange({ visibleOrder: [...prefs.visibleOrder, key] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPrefsChange({ visibleOrder: prefs.visibleOrder.filter((item) => item !== key) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
data-testid="columns-drawer"
|
||||||
|
PaperProps={{ sx: { width: 360, p: 2 } }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6" data-testid="columns-selector-header">
|
||||||
|
Columns
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" data-testid="columns-selector-reset-button" onClick={onReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<IconButton aria-label="Close columns" onClick={onClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
|
Visible
|
||||||
|
</Typography>
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={prefs.visibleOrder} strategy={verticalListSortingStrategy}>
|
||||||
|
<Stack spacing={0.5} sx={{ mb: 2 }}>
|
||||||
|
{prefs.visibleOrder.map((key) => (
|
||||||
|
<SortableVisibleRow key={key} columnKey={key} onToggle={onToggle} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
|
Hidden
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search"
|
||||||
|
value={hiddenQuery}
|
||||||
|
onChange={(event) => setHiddenQuery(event.target.value)}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
data-testid="columns-hidden-search"
|
||||||
|
/>
|
||||||
|
<Stack spacing={0.5} sx={{ mb: 1 }}>
|
||||||
|
{filteredHidden.map((key) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={key}
|
||||||
|
data-testid={`columns-hidden-${key}`}
|
||||||
|
control={
|
||||||
|
<Checkbox checked={false} onChange={(event) => onToggle(key, event.target.checked)} />
|
||||||
|
}
|
||||||
|
label={catalogLabel(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{!viewAllHidden && !hiddenQuery && hidden.length > 5 ? (
|
||||||
|
<Button size="small" onClick={() => setViewAllHidden(true)}>
|
||||||
|
View all
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Alert, Box, 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 type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||||
|
import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer";
|
||||||
import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar";
|
import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar";
|
||||||
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
||||||
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
||||||
|
import { columnsFromCatalogKeys, readyToPayColumns } from "@/dashboard/columns";
|
||||||
import {
|
import {
|
||||||
allInvoicesColumns,
|
loadColumnPrefs,
|
||||||
invoiceAttentionColumns,
|
resetColumnPrefs,
|
||||||
readyToPayColumns,
|
saveColumnPrefs,
|
||||||
} from "@/dashboard/columns";
|
type ColumnPrefs,
|
||||||
|
} from "@/dashboard/column-prefs";
|
||||||
import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters";
|
import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters";
|
||||||
import { InvoiceTabLabel } from "@/dashboard/invoice-tabs";
|
import { InvoiceTabLabel } from "@/dashboard/invoice-tabs";
|
||||||
import { countSelectedRows } from "@/dashboard/selection";
|
import { countSelectedRows } from "@/dashboard/selection";
|
||||||
|
|
@ -25,6 +28,14 @@ const emptySelection: GridRowSelectionModel = {
|
||||||
|
|
||||||
const emptyFilters = EMPTY_INVOICE_FILTERS;
|
const emptyFilters = EMPTY_INVOICE_FILTERS;
|
||||||
|
|
||||||
|
const rowClickSx = {
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const invoiceTabsSx = {
|
const invoiceTabsSx = {
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
"& .MuiTab-root": {
|
"& .MuiTab-root": {
|
||||||
|
|
@ -46,14 +57,6 @@ const invoiceTabsSx = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const rowClickSx = {
|
|
||||||
border: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
backgroundColor: "background.paper",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderRadius: 1,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] {
|
function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] {
|
||||||
const vendorQuery = filters.vendorQuery.trim().toLowerCase();
|
const vendorQuery = filters.vendorQuery.trim().toLowerCase();
|
||||||
return rows.filter((invoice) => {
|
return rows.filter((invoice) => {
|
||||||
|
|
@ -72,11 +75,17 @@ export function DashboardGrids() {
|
||||||
const [tab, setTab] = useState<InvoiceTab>("attention");
|
const [tab, setTab] = useState<InvoiceTab>("attention");
|
||||||
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
||||||
const [filters, setFilters] = useState<InvoiceFilterState>(emptyFilters);
|
const [filters, setFilters] = useState<InvoiceFilterState>(emptyFilters);
|
||||||
const [, setColumnsDrawerOpen] = useState(false);
|
const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false);
|
||||||
const [, setFilterDrawerOpen] = useState(false);
|
const [, setFilterDrawerOpen] = useState(false);
|
||||||
|
const [columnPrefs, setColumnPrefs] = useState<ColumnPrefs>(() => loadColumnPrefs());
|
||||||
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
const { data: invoices = [], isLoading, isError, error } = useInvoices();
|
||||||
const updatePayDate = useUpdateInvoicePayDate();
|
const updatePayDate = useUpdateInvoicePayDate();
|
||||||
|
|
||||||
|
const catalogColumns = useMemo(
|
||||||
|
() => columnsFromCatalogKeys(columnPrefs.visibleOrder),
|
||||||
|
[columnPrefs.visibleOrder],
|
||||||
|
);
|
||||||
|
|
||||||
const openInvoice = (params: GridRowParams<MockInvoice>) => {
|
const openInvoice = (params: GridRowParams<MockInvoice>) => {
|
||||||
navigate("/invoices/:id", { params: { id: params.row.id } });
|
navigate("/invoices/:id", { params: { id: params.row.id } });
|
||||||
};
|
};
|
||||||
|
|
@ -129,6 +138,11 @@ export function DashboardGrids() {
|
||||||
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
|
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateColumnPrefs = (next: ColumnPrefs) => {
|
||||||
|
setColumnPrefs(next);
|
||||||
|
saveColumnPrefs(next);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||||
|
|
@ -253,7 +267,7 @@ export function DashboardGrids() {
|
||||||
<InvoiceDataGrid
|
<InvoiceDataGrid
|
||||||
data-testid="grid-pending"
|
data-testid="grid-pending"
|
||||||
rows={filteredAttention}
|
rows={filteredAttention}
|
||||||
columns={invoiceAttentionColumns}
|
columns={catalogColumns}
|
||||||
onRowClick={openInvoice}
|
onRowClick={openInvoice}
|
||||||
sx={rowClickSx}
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
|
|
@ -263,7 +277,7 @@ export function DashboardGrids() {
|
||||||
<InvoiceDataGrid
|
<InvoiceDataGrid
|
||||||
data-testid="grid-all"
|
data-testid="grid-all"
|
||||||
rows={filteredAll}
|
rows={filteredAll}
|
||||||
columns={allInvoicesColumns}
|
columns={catalogColumns}
|
||||||
onRowClick={openInvoice}
|
onRowClick={openInvoice}
|
||||||
sx={rowClickSx}
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
|
|
@ -289,7 +303,7 @@ export function DashboardGrids() {
|
||||||
<InvoiceDataGrid
|
<InvoiceDataGrid
|
||||||
data-testid="grid-rejected"
|
data-testid="grid-rejected"
|
||||||
rows={filteredRejected}
|
rows={filteredRejected}
|
||||||
columns={allInvoicesColumns}
|
columns={catalogColumns}
|
||||||
onRowClick={openInvoice}
|
onRowClick={openInvoice}
|
||||||
sx={rowClickSx}
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
|
|
@ -299,7 +313,7 @@ export function DashboardGrids() {
|
||||||
<InvoiceDataGrid
|
<InvoiceDataGrid
|
||||||
data-testid="grid-late"
|
data-testid="grid-late"
|
||||||
rows={filteredLate}
|
rows={filteredLate}
|
||||||
columns={allInvoicesColumns}
|
columns={catalogColumns}
|
||||||
onRowClick={openInvoice}
|
onRowClick={openInvoice}
|
||||||
sx={rowClickSx}
|
sx={rowClickSx}
|
||||||
/>
|
/>
|
||||||
|
|
@ -309,6 +323,17 @@ export function DashboardGrids() {
|
||||||
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
|
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<ColumnsDrawer
|
||||||
|
open={columnsDrawerOpen}
|
||||||
|
onClose={() => setColumnsDrawerOpen(false)}
|
||||||
|
prefs={columnPrefs}
|
||||||
|
onPrefsChange={updateColumnPrefs}
|
||||||
|
onReset={() => {
|
||||||
|
const next = resetColumnPrefs();
|
||||||
|
setColumnPrefs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
src/dashboard/column-prefs.test.ts
Normal file
42
src/dashboard/column-prefs.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
COLUMN_PREFS_STORAGE_KEY,
|
||||||
|
defaultColumnPrefs,
|
||||||
|
hiddenColumnKeys,
|
||||||
|
loadColumnPrefs,
|
||||||
|
resetColumnPrefs,
|
||||||
|
saveColumnPrefs,
|
||||||
|
} from "@/dashboard/column-prefs";
|
||||||
|
import { DEFAULT_VISIBLE_COLUMN_KEYS } from "@/dashboard/column-catalog";
|
||||||
|
|
||||||
|
describe("column-prefs", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.removeItem(COLUMN_PREFS_STORAGE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to HTML Visible order", () => {
|
||||||
|
expect(defaultColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists and reloads prefs", () => {
|
||||||
|
saveColumnPrefs({ visibleOrder: ["invoiceId", "vendor", "dueDate"] });
|
||||||
|
expect(loadColumnPrefs().visibleOrder).toEqual([
|
||||||
|
"bolt",
|
||||||
|
"bell",
|
||||||
|
"invoiceId",
|
||||||
|
"vendor",
|
||||||
|
"dueDate",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset restores defaults", () => {
|
||||||
|
saveColumnPrefs({ visibleOrder: ["invoiceId"] });
|
||||||
|
expect(resetColumnPrefs().visibleOrder).toEqual([...DEFAULT_VISIBLE_COLUMN_KEYS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes hidden keys from visible order", () => {
|
||||||
|
const hidden = hiddenColumnKeys(["invoiceId", "vendor"]);
|
||||||
|
expect(hidden).toContain("tagging");
|
||||||
|
expect(hidden).not.toContain("invoiceId");
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/dashboard/column-prefs.ts
Normal file
66
src/dashboard/column-prefs.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import {
|
||||||
|
DEFAULT_VISIBLE_COLUMN_KEYS,
|
||||||
|
INVOICE_COLUMN_CATALOG,
|
||||||
|
LOCKED_COLUMN_KEYS,
|
||||||
|
type InvoiceColumnKey,
|
||||||
|
} from "@/dashboard/column-catalog";
|
||||||
|
|
||||||
|
export const COLUMN_PREFS_STORAGE_KEY = "seahaven-ap.invoice-columns.v1";
|
||||||
|
|
||||||
|
export type ColumnPrefs = {
|
||||||
|
visibleOrder: InvoiceColumnKey[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultColumnPrefs(): ColumnPrefs {
|
||||||
|
return { visibleOrder: [...DEFAULT_VISIBLE_COLUMN_KEYS] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadColumnPrefs(): ColumnPrefs {
|
||||||
|
if (typeof sessionStorage === "undefined") {
|
||||||
|
return defaultColumnPrefs();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(COLUMN_PREFS_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return defaultColumnPrefs();
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as ColumnPrefs;
|
||||||
|
if (!Array.isArray(parsed.visibleOrder) || parsed.visibleOrder.length === 0) {
|
||||||
|
return defaultColumnPrefs();
|
||||||
|
}
|
||||||
|
const known = new Set(INVOICE_COLUMN_CATALOG.map((entry) => entry.key));
|
||||||
|
const visibleOrder = parsed.visibleOrder.filter((key) => known.has(key));
|
||||||
|
const missingLocked = LOCKED_COLUMN_KEYS.filter((key) => !visibleOrder.includes(key));
|
||||||
|
return { visibleOrder: [...missingLocked, ...visibleOrder] };
|
||||||
|
} catch {
|
||||||
|
return defaultColumnPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveColumnPrefs(prefs: ColumnPrefs): void {
|
||||||
|
if (typeof sessionStorage === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(COLUMN_PREFS_STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetColumnPrefs(): ColumnPrefs {
|
||||||
|
const prefs = defaultColumnPrefs();
|
||||||
|
saveColumnPrefs(prefs);
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hiddenColumnKeys(visibleOrder: InvoiceColumnKey[]): InvoiceColumnKey[] {
|
||||||
|
const visible = new Set(visibleOrder);
|
||||||
|
return INVOICE_COLUMN_CATALOG.filter((entry) => !visible.has(entry.key)).map(
|
||||||
|
(entry) => entry.key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isColumnLocked(key: InvoiceColumnKey): boolean {
|
||||||
|
return LOCKED_COLUMN_KEYS.includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogLabel(key: InvoiceColumnKey): string {
|
||||||
|
return INVOICE_COLUMN_CATALOG.find((entry) => entry.key === key)?.label ?? key;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue