diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts index 29bc24c..7089c70 100644 --- a/e2e/dashboard-grids.spec.ts +++ b/e2e/dashboard-grids.spec.ts @@ -53,6 +53,23 @@ test.describe("dashboard grids", () => { /Pay run stub: [2-9]\d* invoice\(s\) selected/, ); + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + await drawer.getByTestId("columns-hidden-search").fill("Payment Date"); + await drawer + .getByTestId("columns-hidden-paymentDate") + .locator('input[type="checkbox"]') + .evaluate((el: HTMLInputElement) => { + el.click(); + }); + await expect( + readyGrid.locator('.MuiDataGrid-columnHeader[data-field="payDate"]'), + ).toBeVisible(); + await page.getByRole("button", { name: "Close columns" }).evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(drawer).toBeHidden(); + const payDateCell = readyGrid.locator('[data-field="payDate"]').nth(1); await payDateCell.dblclick(); const editor = readyGrid diff --git a/e2e/invoice-columns.spec.ts b/e2e/invoice-columns.spec.ts new file mode 100644 index 0000000..e2853cb --- /dev/null +++ b/e2e/invoice-columns.spec.ts @@ -0,0 +1,64 @@ +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(); + }); + + test("locked Vendor column stays visible and Reset clears the hidden search", async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("columns-toggle").click(); + const drawer = page.getByTestId("columns-drawer"); + const vendorBox = drawer + .getByTestId("columns-visible-vendor") + .locator('input[type="checkbox"]'); + await expect(vendorBox).toBeDisabled(); + await vendorBox.evaluate((el: HTMLInputElement) => { + el.disabled = false; + el.click(); + }); + await expect( + page + .getByTestId("grid-pending") + .locator('.MuiDataGrid-columnHeader[data-field="vendorName"]'), + ).toBeVisible(); + await page.getByTestId("tab-ready").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect( + page.getByTestId("grid-ready").locator('.MuiDataGrid-columnHeader[data-field="vendorName"]'), + ).toBeVisible(); + + const search = drawer.getByTestId("columns-hidden-search"); + await search.fill("no-such-column"); + await drawer.getByTestId("columns-selector-reset-button").evaluate((el: HTMLElement) => { + el.click(); + }); + await expect(search).toHaveValue(""); + }); +}); diff --git a/package-lock.json b/package-lock.json index d100c79..7fa5aaf 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", @@ -842,6 +845,59 @@ "node": ">=20.19.0" } }, + "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 440cfe0..7477812 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ }, "packageManager": "npm@12.0.2", "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..36b6d46 --- /dev/null +++ b/src/dashboard/ColumnsDrawer.tsx @@ -0,0 +1,207 @@ +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 }} + slotProps={{ htmlInput: { "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 9d767fa..ab1f03d 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 { columnsForApprovedGrid, columnsFromCatalogKeys } 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"; @@ -72,11 +75,21 @@ 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 approvedColumns = useMemo( + () => columnsForApprovedGrid(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); + const openInvoice = (params: GridRowParams) => { navigate("/invoices/:id", { params: { id: params.row.id } }); }; @@ -129,6 +142,11 @@ export function DashboardGrids() { return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate }); }; + const updateColumnPrefs = (next: ColumnPrefs) => { + setColumnPrefs(next); + saveColumnPrefs(next); + }; + if (isLoading) { return ( @@ -245,7 +263,7 @@ export function DashboardGrids() { @@ -255,7 +273,7 @@ export function DashboardGrids() { @@ -265,7 +283,7 @@ export function DashboardGrids() { setSelection(model)} @@ -281,7 +299,7 @@ export function DashboardGrids() { @@ -291,7 +309,7 @@ export function DashboardGrids() { @@ -301,6 +319,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/approved-columns.ts b/src/dashboard/approved-columns.ts new file mode 100644 index 0000000..b94e671 --- /dev/null +++ b/src/dashboard/approved-columns.ts @@ -0,0 +1,9 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import type { MockInvoice } from "@/mocks"; + +/** Mark a visible pay-date column editable. A hidden Payment Date stays omitted. */ +export function withEditablePayDate(columns: GridColDef[]): GridColDef[] { + return columns.map((column) => + column.field === "payDate" ? { ...column, editable: true } : column, + ); +} 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; +} diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts index a0cdeb1..a3eb9d3 100644 --- a/src/dashboard/columns.test.ts +++ b/src/dashboard/columns.test.ts @@ -1,4 +1,7 @@ +import type { GridColDef } from "@mui/x-data-grid"; import { describe, expect, it } from "vitest"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; +import type { MockInvoice } from "@/mocks"; import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; describe("invoice column date helpers (AP-47)", () => { @@ -26,3 +29,20 @@ describe("invoice column date helpers (AP-47)", () => { expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months"); }); }); + +describe("withEditablePayDate", () => { + const hidden: GridColDef = { field: "vendorName", headerName: "Vendor" }; + const payDate: GridColDef = { field: "payDate", headerName: "Payment Date" }; + + it("leaves Payment Date hidden when the catalog omits it", () => { + const columns = withEditablePayDate([hidden]); + expect(columns.some((column) => column.field === "payDate")).toBe(false); + }); + + it("keeps a single editable pay date when Payment Date is already visible", () => { + const columns = withEditablePayDate([hidden, { ...payDate, editable: false }]); + const matches = columns.filter((column) => column.field === "payDate"); + expect(matches).toHaveLength(1); + expect(matches[0]?.editable).toBe(true); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 3b1ded2..c720972 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -4,6 +4,7 @@ import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNone import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Box, Link as MuiLink, Stack, Typography } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; import type { MockInvoice } from "@/mocks"; @@ -250,6 +251,11 @@ export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef catalogColumnDefs[key]); } +/** Approved tab keeps pay date editable when the drawer shows Payment Date. */ +export function columnsForApprovedGrid(keys: InvoiceColumnKey[]): GridColDef[] { + return withEditablePayDate(columnsFromCatalogKeys(keys)); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber",