mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 09:13:17 +00:00
feat(web): add Columns slide-over with Stampli HTML defaults (AP-45) (#18)
* 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 * feat(web): add Columns slide-over with Stampli HTML defaults Refs: AP-45 * fix(web): address review feedback * 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): keep pay date editable on the approved grid * 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(web): assert the vendor column by its grid field * fix(ci): format invoice mock helpers * fix(web): hide Payment Date when the drawer omits it * fix(web): count filtered rows on invoice tabs * fix(web): close the columns drawer before editing pay date
This commit is contained in:
parent
fad8461274
commit
ee2f7a9902
11 changed files with 529 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
64
e2e/invoice-columns.spec.ts
Normal file
64
e2e/invoice-columns.spec.ts
Normal file
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
207
src/dashboard/ColumnsDrawer.tsx
Normal file
207
src/dashboard/ColumnsDrawer.tsx
Normal file
|
|
@ -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 (
|
||||
<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"
|
||||
slotProps={{ paper: { sx: { width: 360, p: 2 } } }}
|
||||
>
|
||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between", 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={() => {
|
||||
setHiddenQuery("");
|
||||
setViewAllHidden(false);
|
||||
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 }}
|
||||
slotProps={{ htmlInput: { "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 { 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<InvoiceTab>("attention");
|
||||
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
|
||||
const [filters, setFilters] = useState<InvoiceFilterState>(emptyFilters);
|
||||
const [, setColumnsDrawerOpen] = useState(false);
|
||||
const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false);
|
||||
const [, setFilterDrawerOpen] = useState(false);
|
||||
const [columnPrefs, setColumnPrefs] = useState<ColumnPrefs>(() => 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<MockInvoice>) => {
|
||||
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 (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||
|
|
@ -245,7 +263,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-pending"
|
||||
rows={filteredAttention}
|
||||
columns={invoiceAttentionColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -255,7 +273,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-all"
|
||||
rows={filteredAll}
|
||||
columns={allInvoicesColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -265,7 +283,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-ready"
|
||||
rows={filteredApproved}
|
||||
columns={readyToPayColumns}
|
||||
columns={approvedColumns}
|
||||
checkboxSelection
|
||||
rowSelectionModel={selection}
|
||||
onRowSelectionModelChange={(model) => setSelection(model)}
|
||||
|
|
@ -281,7 +299,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-rejected"
|
||||
rows={filteredRejected}
|
||||
columns={allInvoicesColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -291,7 +309,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-late"
|
||||
rows={filteredLate}
|
||||
columns={allInvoicesColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -301,6 +319,17 @@ export function DashboardGrids() {
|
|||
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ColumnsDrawer
|
||||
open={columnsDrawerOpen}
|
||||
onClose={() => setColumnsDrawerOpen(false)}
|
||||
prefs={columnPrefs}
|
||||
onPrefsChange={updateColumnPrefs}
|
||||
onReset={() => {
|
||||
const next = resetColumnPrefs();
|
||||
setColumnPrefs(next);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
9
src/dashboard/approved-columns.ts
Normal file
9
src/dashboard/approved-columns.ts
Normal file
|
|
@ -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<MockInvoice>[]): GridColDef<MockInvoice>[] {
|
||||
return columns.map((column) =>
|
||||
column.field === "payDate" ? { ...column, editable: true } : column,
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<MockInvoice> = { field: "vendorName", headerName: "Vendor" };
|
||||
const payDate: GridColDef<MockInvoice> = { 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Moc
|
|||
return keys.map((key) => catalogColumnDefs[key]);
|
||||
}
|
||||
|
||||
/** Approved tab keeps pay date editable when the drawer shows Payment Date. */
|
||||
export function columnsForApprovedGrid(keys: InvoiceColumnKey[]): GridColDef<MockInvoice>[] {
|
||||
return withEditablePayDate(columnsFromCatalogKeys(keys));
|
||||
}
|
||||
|
||||
export const readyToPayColumns: GridColDef<MockInvoice>[] = [
|
||||
{
|
||||
field: "invoiceNumber",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue