initial setup pmsGrid
This commit is contained in:
parent
a2f105dd41
commit
cbe0b188c4
@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
253
src/services/pmsGrid/BasicTable.jsx
Normal file
253
src/services/pmsGrid/BasicTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/services/pmsGrid/GridService.js
Normal file
65
src/services/pmsGrid/GridService.js
Normal 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));
|
||||
});
|
||||
}
|
||||
463
src/services/pmsGrid/PmsGrid.jsx
Normal file
463
src/services/pmsGrid/PmsGrid.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/services/pmsGrid/index.js
Normal file
10
src/services/pmsGrid/index.js
Normal 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";
|
||||
34
src/services/pmsGrid/pms-grid.css
Normal file
34
src/services/pmsGrid/pms-grid.css
Normal 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;
|
||||
}
|
||||
46
src/services/pmsGrid/useGrid.js
Normal file
46
src/services/pmsGrid/useGrid.js
Normal 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,
|
||||
};
|
||||
};
|
||||
163
src/services/pmsGrid/useGridCore.js
Normal file
163
src/services/pmsGrid/useGridCore.js
Normal 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,
|
||||
};
|
||||
}
|
||||
19
src/services/pmsGrid/utils.js
Normal file
19
src/services/pmsGrid/utils.js
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user