diff --git a/e2e/invoice-columns.spec.ts b/e2e/invoice-columns.spec.ts new file mode 100644 index 0000000..6a7ff2f --- /dev/null +++ b/e2e/invoice-columns.spec.ts @@ -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(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 732ca3e..30cb622 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "packages/*" ], "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/styled": "^11.14.1", "@fontsource/ibm-plex-sans": "^5.3.0", @@ -756,6 +759,59 @@ "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": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", diff --git a/package.json b/package.json index 6a1d4d4..dc8ab0a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ }, "packageManager": "npm@11.16.0", "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/styled": "^11.14.1", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/src/dashboard/ColumnsDrawer.tsx b/src/dashboard/ColumnsDrawer.tsx new file mode 100644 index 0000000..415d3b6 --- /dev/null +++ b/src/dashboard/ColumnsDrawer.tsx @@ -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 ( + + + + + onToggle(columnKey, event.target.checked)} + /> + } + label={catalogLabel(columnKey)} + /> + + ); +} + +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 ( + + + + Columns + + + + + + + + + + + Visible + + + + + {prefs.visibleOrder.map((key) => ( + + ))} + + + + + + Hidden + + setHiddenQuery(event.target.value)} + sx={{ mb: 1 }} + data-testid="columns-hidden-search" + /> + + {filteredHidden.map((key) => ( + onToggle(key, event.target.checked)} /> + } + label={catalogLabel(key)} + /> + ))} + + {!viewAllHidden && !hiddenQuery && hidden.length > 5 ? ( + + ) : null} + + ); +} diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index e7d8a55..93d012b 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -1,14 +1,17 @@ import { useMemo, useState } from "react"; import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material"; import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid"; +import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer"; import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; +import { columnsFromCatalogKeys, readyToPayColumns } from "@/dashboard/columns"; import { - allInvoicesColumns, - invoiceAttentionColumns, - readyToPayColumns, -} from "@/dashboard/columns"; + loadColumnPrefs, + resetColumnPrefs, + saveColumnPrefs, + type ColumnPrefs, +} from "@/dashboard/column-prefs"; import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters"; import { InvoiceTabLabel } from "@/dashboard/invoice-tabs"; import { countSelectedRows } from "@/dashboard/selection"; @@ -25,6 +28,14 @@ const emptySelection: GridRowSelectionModel = { const emptyFilters = EMPTY_INVOICE_FILTERS; +const rowClickSx = { + border: 1, + borderColor: "divider", + backgroundColor: "background.paper", + cursor: "pointer", + borderRadius: 1, +} as const; + const invoiceTabsSx = { minHeight: 44, "& .MuiTab-root": { @@ -46,14 +57,6 @@ const invoiceTabsSx = { }, } as const; -const rowClickSx = { - border: 1, - borderColor: "divider", - backgroundColor: "background.paper", - cursor: "pointer", - borderRadius: 1, -} as const; - function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] { const vendorQuery = filters.vendorQuery.trim().toLowerCase(); return rows.filter((invoice) => { @@ -72,11 +75,17 @@ export function DashboardGrids() { const [tab, setTab] = useState("attention"); const [selection, setSelection] = useState(emptySelection); const [filters, setFilters] = useState(emptyFilters); - const [, setColumnsDrawerOpen] = useState(false); + const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false); const [, setFilterDrawerOpen] = useState(false); + const [columnPrefs, setColumnPrefs] = useState(() => loadColumnPrefs()); const { data: invoices = [], isLoading, isError, error } = useInvoices(); const updatePayDate = useUpdateInvoicePayDate(); + const catalogColumns = useMemo( + () => columnsFromCatalogKeys(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); + const openInvoice = (params: GridRowParams) => { navigate("/invoices/:id", { params: { id: params.row.id } }); }; @@ -129,6 +138,11 @@ export function DashboardGrids() { return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate }); }; + const updateColumnPrefs = (next: ColumnPrefs) => { + setColumnPrefs(next); + saveColumnPrefs(next); + }; + if (isLoading) { return ( @@ -253,7 +267,7 @@ export function DashboardGrids() { @@ -263,7 +277,7 @@ export function DashboardGrids() { @@ -289,7 +303,7 @@ export function DashboardGrids() { @@ -299,7 +313,7 @@ export function DashboardGrids() { @@ -309,6 +323,17 @@ export function DashboardGrids() { Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices + + setColumnsDrawerOpen(false)} + prefs={columnPrefs} + onPrefsChange={updateColumnPrefs} + onReset={() => { + const next = resetColumnPrefs(); + setColumnPrefs(next); + }} + /> ); } diff --git a/src/dashboard/column-prefs.test.ts b/src/dashboard/column-prefs.test.ts new file mode 100644 index 0000000..c1ba789 --- /dev/null +++ b/src/dashboard/column-prefs.test.ts @@ -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"); + }); +}); diff --git a/src/dashboard/column-prefs.ts b/src/dashboard/column-prefs.ts new file mode 100644 index 0000000..da750ea --- /dev/null +++ b/src/dashboard/column-prefs.ts @@ -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; +}