mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-01 02:43:16 +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/*"
|
||||
],
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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 { 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<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 openInvoice = (params: GridRowParams<MockInvoice>) => {
|
||||
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 (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||
|
|
@ -253,7 +267,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-pending"
|
||||
rows={filteredAttention}
|
||||
columns={invoiceAttentionColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -263,7 +277,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-all"
|
||||
rows={filteredAll}
|
||||
columns={allInvoicesColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -289,7 +303,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-rejected"
|
||||
rows={filteredRejected}
|
||||
columns={allInvoicesColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -299,7 +313,7 @@ export function DashboardGrids() {
|
|||
<InvoiceDataGrid
|
||||
data-testid="grid-late"
|
||||
rows={filteredLate}
|
||||
columns={allInvoicesColumns}
|
||||
columns={catalogColumns}
|
||||
onRowClick={openInvoice}
|
||||
sx={rowClickSx}
|
||||
/>
|
||||
|
|
@ -309,6 +323,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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