initial setup pmsGrid

This commit is contained in:
pramod.mahajan 2025-10-31 00:51:58 +05:30
parent a2f105dd41
commit cbe0b188c4
9 changed files with 1056 additions and 1 deletions

View File

@ -54,6 +54,8 @@ import ProjectPage from "../pages/project/ProjectPage";
import { ComingSoonPage } from "../pages/Misc/ComingSoonPage";
import ImageGalleryPage from "../pages/Gallary/ImageGallaryPage";
import CollectionPage from "../pages/collections/CollectionPage";
import DemoBOQGrid from "../services/pmsGrid/BasicTable";
const router = createBrowserRouter(
[
{
@ -106,7 +108,7 @@ const router = createBrowserRouter(
{ path: "/organizations", element: <OrganizationPage /> },
{ path: "/help/support", element: <Support /> },
{ path: "/help/docs", element: <Documentation /> },
{ path: "/help/connect", element: <Connect /> },
{ path: "/help/connect", element: <DemoBOQGrid /> },
],
},
],

View File

@ -0,0 +1,253 @@
import React, { useEffect ,useRef} from "react";
import { PmsGrid } from "./index";
import { initPopover } from "./GridService";
/**
* CIVIL BOQ / INVENTORY DEMO DATA
* Each row = BOQ item, grouped by "Category"
*/
// BOQ data
const boqData = [
{
id: 1,
category: "Concrete Works",
itemCode: "C-001",
description: "M20 Concrete for foundation",
unit: "Cum",
quantity: 120,
rate: 5200,
site: "Green City - Tower A",
vendor: "UltraTech",
status: "Completed",
},
{
id: 2,
category: "Concrete Works",
itemCode: "C-002",
description: "M25 Concrete for columns",
unit: "Cum",
quantity: 80,
rate: 5600,
site: "Green City - Tower B",
vendor: "ACC",
status: "In Progress",
},
{
id: 3,
category: "Steel Reinforcement",
itemCode: "S-101",
description: "TMT Bars Fe500 (10mm)",
unit: "MT",
quantity: 15,
rate: 64000,
site: "Skyline Heights - Wing C",
vendor: "JSW Steel",
status: "Pending",
},
{
id: 4,
category: "Steel Reinforcement",
itemCode: "S-102",
description: "TMT Bars Fe500 (16mm)",
unit: "MT",
quantity: 25,
rate: 64500,
site: "Skyline Heights - Wing D",
vendor: "TATA Steel",
status: "In Progress",
},
{
id: 5,
category: "Masonry Works",
itemCode: "M-001",
description: "Brick Masonry 230mm thick wall",
unit: "Sqm",
quantity: 280,
rate: 900,
site: "Sunshine Plaza",
vendor: "Shree Bricks",
status: "Completed",
},
{
id: 6,
category: "Plastering Works",
itemCode: "P-001",
description: "Internal Plaster 12mm thick",
unit: "Sqm",
quantity: 1200,
rate: 250,
site: "Skyline Heights",
vendor: "L&T Finishes",
status: "Completed",
},
];
// BOQ columns
const boqColumns = [
{ key: "itemCode", title: "Item Code", sortable: true, pinned: "left" },
{ key: "description", title: "Description", sortable: true, width: 300 },
{ key: "category", title: "Category", sortable: true },
{ key: "unit", title: "Unit", width: 80 },
{ key: "quantity", title: "Qty", sortable: true, width: 100 },
{
key: "rate",
title: "Rate (₹)",
sortable: true,
width: 120,
render: (r) => <span>{r.rate.toLocaleString()}</span>,
},
{
key: "amount",
title: "Amount (₹)",
sortable: true,
width: 130,
render: (r) => (
<span className="fw-semibold text-success">
{(r.quantity * r.rate).toLocaleString()}
</span>
),
},
{ key: "vendor", title: "Vendor", sortable: true, width: 180 },
{ key: "site", title: "Site Location", sortable: true, width: 200 },
{
key: "status",
title: "Status",
sortable: true,
width: 120,
render: (r) => (
<span
className={`badge bg-${
r.status === "Completed"
? "success"
: r.status === "Pending"
? "warning"
: "info"
}`}
>
{r.status}
</span>
),
},
];
// Main component
export default function DemoBOQGrid() {
const wrapperRef = useRef(null);
// initialize scrollbar
useEffect(() => {
if (!wrapperRef.current) return;
const ps = new PerfectScrollbar(wrapperRef.current, {
wheelPropagation: false,
suppressScrollX: false,
});
return () => ps.destroy();
}, []);
// initialize bootstrap popover
useEffect(() => {
initPopover();
}, []);
return (
<div className="container-fluid py-3">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="fw-semibold">📦 BOQ (Bill of Quantities)</h5>
<button
type="button"
className="btn btn-outline-primary"
data-bs-toggle="popover"
data-bs-placement="right"
data-bs-content="This is a Bootstrap Popover initialized via CDN!"
>
<i className="bx bx-help-circle"></i> Popover Test
</button>
</div>
<div className="card p-3">
<div ref={wrapperRef} style={{ maxHeight: "70vh", overflow: "auto" }}>
<PmsGrid
data={boqData.map((r) => ({
...r,
amount: r.quantity * r.rate,
}))}
columns={boqColumns}
features={{
search: true,
selection: true,
pagination: true,
export: true,
pinning: true,
resizing: true,
reorder: true,
columnVisibility: true,
expand: true,
aggregation: true,
maxHeight: "60vh",
actions: (row, toggleExpand) => (
<button
className="btn btn-sm btn-outline-primary"
data-bs-toggle="popover"
data-bs-content={`Expand details for ${row.itemCode}`}
onClick={() => toggleExpand(row.id)}
>
<i className="bx bx-detail me-1"></i> Details
</button>
),
}}
renderExpanded={(row) => (
<div className="p-3 bg-light border rounded">
<h6 className="fw-semibold mb-2">Item Details</h6>
<div className="row small">
<div className="col-md-4 mb-2">
<strong>Item Code:</strong> {row.itemCode}
</div>
<div className="col-md-4 mb-2">
<strong>Category:</strong> {row.category}
</div>
<div className="col-md-4 mb-2">
<strong>Unit:</strong> {row.unit}
</div>
<div className="col-md-4 mb-2">
<strong>Quantity:</strong> {row.quantity}
</div>
<div className="col-md-4 mb-2">
<strong>Rate:</strong> {row.rate.toLocaleString()}
</div>
<div className="col-md-4 mb-2">
<strong>Total Amount:</strong>{" "}
<span className="text-success fw-semibold">
{(row.quantity * row.rate).toLocaleString()}
</span>
</div>
<div className="col-md-6 mb-2">
<strong>Vendor:</strong> {row.vendor}
</div>
<div className="col-md-6 mb-2">
<strong>Site:</strong> {row.site}
</div>
<div className="col-md-12">
<strong>Status:</strong>{" "}
<span
className={`badge bg-${
row.status === "Completed"
? "success"
: row.status === "Pending"
? "warning"
: "info"
}`}
>
{row.status}
</span>
</div>
</div>
</div>
)}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,65 @@
export const sortArray = (data = [], columnKey, direction = 'asc') => {
if (!columnKey) return data;
const dir = direction === 'asc' ? 1 : -1;
return [...data].sort((a, b) => {
const A = a[columnKey];
const B = b[columnKey];
if (A === B) return 0;
if (A == null) return -1 * dir;
if (B == null) return 1 * dir;
if (typeof A === 'number' && typeof B === 'number') return (A - B) * dir;
return String(A).localeCompare(String(B)) * dir;
});
};
export const filterArray = (data = [], searchTerm = '', columns = []) => {
if (!searchTerm) return data;
const q = String(searchTerm).toLowerCase();
return data.filter(row =>
columns.some(col => {
const val = row[col];
if (val === undefined || val === null) return false;
return String(val).toLowerCase().includes(q);
})
);
};
export const paginate = (data = [], page = 1, pageSize = 10) => {
const total = data.length;
const from = (page - 1) * pageSize;
const to = from + pageSize;
return {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
rows: data.slice(from, to),
};
};
// selection helpers
export const toggleItem = (selected = new Set(), id) => {
const s = new Set(selected);
if (s.has(id)) s.delete(id);
else s.add(id);
return s;
};
export const selectAll = (rows = [], idKey = 'id') =>
new Set(rows.map(r => r[idKey]));
export const clearSelection = () => new Set();
// src/services/pmsGrid/usePmsPopup.js
export function initPopover(selector = '[data-bs-toggle="popover"]') {
if (!window.bootstrap || !window.bootstrap.Popover) {
console.warn('⚠️ Bootstrap JS not found — make sure you included bootstrap.bundle.min.js in index.html');
return;
}
document.addEventListener('DOMContentLoaded', () => {
const elements = document.querySelectorAll(selector);
if (!elements.length) return;
elements.forEach(el => new window.bootstrap.Popover(el));
});
}

View File

@ -0,0 +1,463 @@
import React, { useRef } from "react";
import { useGridCore } from "./useGridCore";
import { exportToCSV } from "./utils";
import "./pms-grid.css";
/*
Props:
- columns: [{ key, title, width, pinned: 'left'|'right'|null, sortable, render, aggregate }]
- data OR serverMode + fetcher
- rowKey
- features: { selection, search, export, pagination, pinning, resizing, reorder, grouping, aggregation, expand }
- renderExpanded
*/
export default function PmsGrid({
columns = [],
data,
serverMode = false,
fetcher,
rowKey = "id",
features = {},
renderExpanded,
}) {
const grid = useGridCore({
data,
serverMode,
fetcher,
rowKey,
initialPageSize: features.pageSize || 25,
columns,
});
const wrapperRef = useRef();
const {
rows,
page,
totalPages,
pageSize,
setPage,
setPageSize,
search,
setSearch,
selected,
toggleSelect,
selectAllOnPage,
deselectAllOnPage,
changeSort,
sortBy,
visibleColumns,
colState,
updateColumn,
loading,
totalRows,
expanded,
toggleExpand,
setColState,
} = grid;
// simple pin toggle
const togglePin = (key) => {
const col = colState.find((c) => c.key === key);
updateColumn(key, { pinned: col.pinned === "left" ? null : "left" });
};
// resizing via mouse down on handle
const onResizeMouseDown = (e, key) => {
e.preventDefault();
const startX = e.clientX;
const col = colState.find((c) => c.key === key);
const startWidth = col.width || 120;
const onMove = (ev) => {
const diff = ev.clientX - startX;
const newWidth = Math.max(60, startWidth + diff);
updateColumn(key, { width: newWidth });
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
// reorder columns (drag/drop)
const dragState = useRef({ from: null });
const onDragStart = (e, key) => {
dragState.current.from = key;
e.dataTransfer.effectAllowed = "move";
};
const onDrop = (e, toKey) => {
e.preventDefault();
const fromKey = dragState.current.from;
if (!fromKey || fromKey === toKey) return;
const from = colState.find((c) => c.key === fromKey);
const to = colState.find((c) => c.key === toKey);
if (!from || !to) return;
// swap orders
setColState((prev) => {
const next = prev.map((p) => ({ ...p }));
const f = next.find((p) => p.key === fromKey);
const t = next.find((p) => p.key === toKey);
const tmp = f.order;
f.order = t.order;
t.order = tmp;
return next.sort((a, b) => a.order - b.order);
});
};
// group & aggregate (simple client-side grouping by column key)
const groupBy = features.groupByKey || null;
const groupedRows = React.useMemo(() => {
if (!groupBy) return null;
const map = {};
rows.forEach((r) => {
const g = String(r[groupBy] ?? "Unknown");
if (!map[g]) map[g] = [];
map[g].push(r);
});
const groups = Object.keys(map).map((k) => ({ key: k, items: map[k] }));
// apply aggregation
const aggregates = groups.map((g) => {
const ag = {};
visibleColumns.forEach((col) => {
if (col.aggregate) {
ag[col.key] = col.aggregate(g.items.map((it) => it[col.key]));
}
});
return { ...g, aggregates: ag };
});
return aggregates;
}, [rows, groupBy, visibleColumns]);
const currentRows = rows;
return (
<div className="pms-grid">
<div className="d-flex justify-content-between mb-2">
<div className="d-flex gap-2 align-items-center">
{features.search && (
<input
className="form-control form-control-sm"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
{features.export && (
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
exportToCSV(
currentRows,
colState.filter((c) => c.visible)
)
}
>
Export CSV
</button>
)}
</div>
<div className="d-flex gap-2">
{features.columnVisibility && (
<ColumnVisibilityPanel
columns={colState}
onToggle={(k) =>
updateColumn(k, {
visible: !colState.find((c) => c.key === k).visible,
})
}
/>
)}
{features.pageSizeSelector && (
<select
className="form-select form-select-sm"
style={{ width: "80px" }}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
>
{[10, 25, 50, 100].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
)}
</div>
</div>
<div
ref={wrapperRef}
className="grid-wrapper"
style={{ maxHeight: features.maxHeight || "60vh" }}
>
<table className="table table-sm table-bordered mb-0">
<thead
className="table-light"
style={{ position: "sticky", top: 0, zIndex: 3 }}
>
<tr>
{features.selection && (
<th style={{ width: 32 }} className="text-center">
<input
type="checkbox"
className="form-check-input"
checked={
currentRows.length > 0 &&
currentRows.every((r) => selected.has(r[rowKey]))
}
onChange={(e) =>
e.target.checked
? selectAllOnPage(currentRows)
: deselectAllOnPage(currentRows)
}
/>
</th>
)}
{visibleColumns.map((col) => {
const style = {
minWidth: col.width || 120,
width: col.width || undefined,
};
if (col.pinned) style.position = "sticky";
if (col.pinned === "left")
style.left = `${getLeftOffset(colState, col.key)}px`;
if (col.pinned === "right")
style.right = `${getRightOffset(colState, col.key)}px`;
return (
<th
key={col.key}
draggable={features.reorder}
onDragStart={(e) => onDragStart(e, col.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, col.key)}
className={`pms-col-header ${col.pinned ? "pinned" : ""}`}
style={style}
>
<div className="d-flex align-items-center justify-content-between">
<div
onClick={() => col.sortable && changeSort(col.key)}
style={{ cursor: col.sortable ? "pointer" : "default" }}
>
<strong>{col.title}</strong>
{sortBy.key === col.key && (
<small className="ms-2 text-muted">
[{sortBy.dir}]
</small>
)}
</div>
<div className="d-flex align-items-center gap-1">
{features.pinning && (
<button
className="btn btn-sm btn-link p-0"
title="Pin/Unpin"
onClick={() => togglePin(col.key)}
>
<i className="bx bx-pin"></i>
</button>
)}
{features.resizing && (
<div
className="resize-handle"
onMouseDown={(e) => onResizeMouseDown(e, col.key)}
/>
)}
</div>
</div>
</th>
);
})}
{features.actions && (
<th
className="text-center sticky-action-column"
style={{ position: "sticky", right: 0, zIndex: 5 }}
>
Actions
</th>
)}
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td
colSpan={
visibleColumns.length +
(features.selection ? 1 : 0) +
(features.actions ? 1 : 0)
}
className="text-center py-4"
>
Loading...
</td>
</tr>
)}
{!loading && groupBy && groupedRows && groupedRows.length > 0
? groupedRows.map((g) => (
<React.Fragment key={g.key}>
<tr className="table-secondary">
<td
colSpan={
visibleColumns.length +
(features.selection ? 1 : 0) +
(features.actions ? 1 : 0)
}
>
<strong>{g.key}</strong>
{features.aggregation &&
Object.keys(g.aggregates).length > 0 && (
<small className="ms-3 text-muted">
{Object.entries(g.aggregates)
.map(([k, v]) => `${k}: ${v}`)
.join(" | ")}
</small>
)}
</td>
</tr>
{g.items.map((row) => renderRow(row))}
</React.Fragment>
))
: currentRows.map((row) => renderRow(row))}
</tbody>
</table>
</div>
{features.pagination && (
<div className="d-flex justify-content-between align-items-center mt-2">
<div>
<small>{totalRows} rows</small>
</div>
<div>
<button
className="btn btn-sm btn-outline-secondary me-1"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
Prev
</button>
<span className="mx-2">
{page}/{totalPages}
</span>
<button
className="btn btn-sm btn-outline-secondary"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
)}
</div>
);
// render a single row (function hoisted so it can reference visibleColumns)
function renderRow(row) {
return (
<React.Fragment key={row[rowKey]}>
<tr>
{features.selection && (
<td className="text-center p-2">
<input
type="checkbox"
className="form-check-input"
checked={selected.has(row[rowKey])}
onChange={() => toggleSelect(row[rowKey])}
/>
</td>
)}
{visibleColumns.map((col) => {
const style = {
minWidth: col.width || 120,
width: col.width || undefined,
};
if (col.pinned) style.position = "sticky";
if (col.pinned === "left")
style.left = `${getLeftOffset(colState, col.key)}px`;
if (col.pinned === "right")
style.right = `${getRightOffset(colState, col.key)}px`;
return (
<td key={col.key} style={style}>
{col.render ? col.render(row) : row[col.key] ?? ""}
</td>
);
})}
{features.actions && (
<td className="text-center sticky-action-column">
{features.actions(row, toggleExpand)}
</td>
)}
</tr>
{features.expand && expanded.has(row[rowKey]) && renderExpanded && (
<tr className="table-active">
<td
colSpan={
visibleColumns.length +
(features.selection ? 1 : 0) +
(features.actions ? 1 : 0)
}
>
{renderExpanded(row)}
</td>
</tr>
)}
</React.Fragment>
);
}
}
// small helpers to compute sticky offsets
function getLeftOffset(colState, key) {
let offset = 0;
for (const c of colState
.filter((c) => c.visible)
.sort((a, b) => a.order - b.order)) {
if (c.key === key) return offset;
if (c.pinned === "left") offset += c.width || 120;
}
return offset;
}
function getRightOffset(colState, key) {
let offset = 0;
const rightCols = colState
.filter((c) => c.visible && c.pinned === "right")
.sort((a, b) => b.order - a.order);
for (const c of rightCols) {
if (c.key === key) return offset;
offset += c.width || 120;
}
return offset;
}
/* ColumnVisibilityPanel component (inline) */
function ColumnVisibilityPanel({ columns, onToggle }) {
return (
<div className="dropdown">
<button
className="btn btn-sm btn-outline-secondary"
data-bs-toggle="dropdown"
>
Columns
</button>
<div className="dropdown-menu dropdown-menu-end p-2">
{columns.map((c) => (
<div key={c.key} className="form-check">
<input
className="form-check-input"
type="checkbox"
id={`colvis-${c.key}`}
checked={c.visible !== false}
onChange={() => onToggle(c.key)}
/>
<label className="form-check-label" htmlFor={`colvis-${c.key}`}>
{c.title}
</label>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
// src/lib/pms-grid/index.js
// ✅ Main Grid Component
export { default as PmsGrid } from "./PmsGrid";
// ✅ Core logic hook
export * from "./useGridCore";
// ✅ Utilities (like exportToCSV)
export * from "./utils";

View File

@ -0,0 +1,34 @@
.grid-wrapper {
max-height: 60vh;
overflow-y: auto;
overflow-x: auto;
position: relative;
}
.grid-wrapper thead {
position: sticky;
top: 0;
z-index: 3;
background: #fff;
}
.resize-handle {
width: 10px;
cursor: col-resize;
height: 28px;
display: inline-block;
}
.pms-col-header.pinned {
background: #fff;
z-index: 4;
}
.sticky-action-column {
position: sticky;
right: 0;
z-index: 5;
background: #fff;
}

View File

@ -0,0 +1,46 @@
import { useState, useMemo, useCallback } from "react";
import { sortArray, filterArray, paginate, toggleItem, selectAll, clearSelection } from "./GridService";
export const useGrid = ({ data = [], columns = [], idKey = "id", initialPageSize = 10 }) => {
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState({ key: null, dir: "asc" });
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const [selected, setSelected] = useState(new Set());
const [expanded, setExpanded] = useState(new Set());
const filtered = useMemo(() => filterArray(data, search, columns.map(c => c.key)), [data, search, columns]);
const sorted = useMemo(() => sortArray(filtered, sortBy.key, sortBy.dir), [filtered, sortBy]);
const pageResult = useMemo(() => paginate(sorted, page, pageSize), [sorted, page, pageSize]);
const toggleSelect = useCallback(id => setSelected(prev => toggleItem(prev, id)), []);
const selectAllOnPage = useCallback(rows => setSelected(selectAll(rows, idKey)), [idKey]);
const deselectAllOnPage = useCallback(rows => setSelected(prev => {
const s = new Set(prev);
rows.forEach(r => s.delete(r[idKey]));
return s;
}), [idKey]);
const clearAllSelection = useCallback(() => setSelected(clearSelection()), []);
const changeSort = useCallback(key => {
setSortBy(prev => (prev.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" }));
}, []);
const toggleExpand = useCallback(id => {
setExpanded(prev => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
return {
search, setSearch,
sortBy, changeSort,
page, setPage,
pageSize, setPageSize,
selected, toggleSelect, selectAllOnPage, deselectAllOnPage, clearAllSelection,
expanded, toggleExpand,
pageResult,
totalRows: sorted.length,
};
};

View File

@ -0,0 +1,163 @@
import { useState, useMemo, useCallback, useEffect } from "react";
/*
options:
- data (array) OR serverMode: true + fetcher({ page, pageSize, sortBy, filter, search })
- rowKey
- initialPageSize
*/
export function useGridCore({ data, serverMode = false, fetcher, rowKey = "id", initialPageSize = 25, columns = [] }) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState({ key: null, dir: "asc" });
const [selected, setSelected] = useState(new Set());
const [expanded, setExpanded] = useState(new Set());
const [colState, setColState] = useState(() =>
columns.map((c, i) => ({ ...c, visible: c.visible !== false, order: i }))
);
const [totalRows, setTotalRows] = useState(data ? data.length : 0);
const [loading, setLoading] = useState(false);
const [serverRows, setServerRows] = useState([]);
// client-side derived rows
const clientFiltered = useMemo(() => {
if (!data) return [];
const q = (search || "").toLowerCase();
const filtered = q
? data.filter(r =>
Object.values(r).some(v =>
String(v ?? "").toLowerCase().includes(q)
)
)
: data;
// sort if needed
if (sortBy.key) {
const dir = sortBy.dir === "asc" ? 1 : -1;
filtered.sort((a, b) => {
const A = a[sortBy.key],
B = b[sortBy.key];
if (A == null && B == null) return 0;
if (A == null) return -1 * dir;
if (B == null) return 1 * dir;
if (typeof A === "number" && typeof B === "number") return (A - B) * dir;
return String(A).localeCompare(String(B)) * dir;
});
}
setTotalRows(filtered.length);
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [data, search, sortBy, page, pageSize]);
// server-side fetch
const fetchServer = useCallback(async () => {
if (!serverMode || typeof fetcher !== "function") return;
setLoading(true);
try {
const resp = await fetcher({ page, pageSize, sortBy, search });
// expected: { rows: [], total }
setServerRows(resp.rows || []);
setTotalRows(resp.total || (resp.rows ? resp.rows.length : 0));
} finally {
setLoading(false);
}
}, [serverMode, fetcher, page, pageSize, sortBy, search]);
useEffect(() => {
if (serverMode) fetchServer();
}, [serverMode, fetchServer]);
// selection
const toggleSelect = useCallback(id => {
setSelected(prev => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const selectAllOnPage = useCallback(
rows => {
setSelected(prev => {
const s = new Set(prev);
rows.forEach(r => s.add(r[rowKey]));
return s;
});
},
[rowKey]
);
const deselectAllOnPage = useCallback(
rows => {
setSelected(prev => {
const s = new Set(prev);
rows.forEach(r => s.delete(r[rowKey]));
return s;
});
},
[rowKey]
);
const toggleExpand = useCallback(id => {
setExpanded(prev => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
}, []);
const changeSort = useCallback(key => {
setSortBy(prev =>
prev.key === key
? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
: { key, dir: "asc" }
);
setPage(1);
}, []);
const visibleColumns = useMemo(
() => colState.filter(c => c.visible).sort((a, b) => a.order - b.order),
[colState]
);
const rows = serverMode ? serverRows : clientFiltered;
const totalPages = Math.max(1, Math.ceil((totalRows || 0) / pageSize));
// update columns externally (reorder/pin/resize)
const updateColumn = useCallback((key, patch) => {
setColState(prev => prev.map(c => (c.key === key ? { ...c, ...patch } : c)));
}, []);
return {
// state
page,
setPage,
pageSize,
setPageSize,
totalRows,
totalPages,
loading,
search,
setSearch,
sortBy,
changeSort,
selected,
toggleSelect,
selectAllOnPage,
deselectAllOnPage,
setSelected,
expanded,
toggleExpand,
colState,
visibleColumns,
updateColumn,
setColState,
// data
rows,
// mode helpers
serverMode,
};
}

View File

@ -0,0 +1,19 @@
export function exportToCSV(rows = [], columns = [], filename = "export.csv") {
const cols = columns.filter(c => c.visible !== false);
const header = cols.map(c => `"${(c.title || c.key).replace(/"/g, '""')}"`).join(",");
const lines = rows.map(r => cols.map(c => {
const v = c.exportValue ? c.exportValue(r) : (r[c.key] ?? "");
return `"${String(v).replace(/"/g,'""')}"`;
}).join(","));
const csv = [header, ...lines].join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", filename);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}