685 lines
23 KiB
JavaScript
685 lines
23 KiB
JavaScript
import React, { useRef, useState } from "react";
|
|
import { useGridCore } from "./useGridCore";
|
|
import { exportToCSV } from "./utils";
|
|
import "./pms-grid.css";
|
|
import PmsHeaderOption from "./PmsHeaderOption";
|
|
import FilterApplied from "./FilterApplied";
|
|
|
|
/*
|
|
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 = [],
|
|
grid,
|
|
loading = false,
|
|
features = {},
|
|
rowKey = "id",
|
|
isDropdown = false,
|
|
renderExpanded,
|
|
}) {
|
|
const [isFullScreen, setFullScreen] = useState(false);
|
|
const [activeCell, setActiveCell] = useState(null);
|
|
const wrapperRef = useRef();
|
|
const {
|
|
rows,
|
|
page,
|
|
totalPages,
|
|
pageSize,
|
|
setPage,
|
|
setPageSize,
|
|
setGroupBy,
|
|
groupBy,
|
|
search,
|
|
setSearch,
|
|
selected,
|
|
toggleSelect,
|
|
selectAllOnPage,
|
|
deselectAllOnPage,
|
|
changeSort,
|
|
sortBy,
|
|
visibleColumns,
|
|
colState,
|
|
updateColumn,
|
|
error,
|
|
totalRows,
|
|
expanded,
|
|
toggleExpand,
|
|
setColState,
|
|
// onAdvanceFilters,
|
|
// setAdanceFilter,
|
|
|
|
advanceFilters,
|
|
setColumnAdvanceFilter,
|
|
} = grid;
|
|
// --- Pin / Unpin helpers ---
|
|
const pinColumn = (key, side) => {
|
|
const col = colState.find((c) => c.key === key);
|
|
if (!col) return;
|
|
// if already pinned to that side → unpin
|
|
const newPinned = col.pinned === side || side === "none" ? null : side;
|
|
updateColumn(key, { pinned: newPinned });
|
|
};
|
|
|
|
const unpinColumn = (key) => {
|
|
updateColumn(key, { pinned: null });
|
|
};
|
|
|
|
// 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 ${
|
|
isFullScreen ? "card card-action card-fullscreen p-4" : ""
|
|
}`}
|
|
>
|
|
<div className="row ">
|
|
<div className="col-8">
|
|
<div className="d-flex flex-row gap-2 gap-2 ">
|
|
<div>
|
|
{features.search && (
|
|
<input
|
|
type="search"
|
|
className="form-control form-control-sm"
|
|
placeholder="Search..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
{features.export && (
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() =>
|
|
exportToCSV(
|
|
currentRows,
|
|
colState.filter((c) => c.visible)
|
|
)
|
|
}
|
|
>
|
|
Export CSV
|
|
</button>
|
|
)}
|
|
{features.grouping && (
|
|
<div className="dropdown ">
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
data-bs-toggle="dropdown"
|
|
>
|
|
Group By
|
|
</button>
|
|
|
|
<div className="dropdown-menu px-1">
|
|
{visibleColumns
|
|
.filter((c) => c.groupable)
|
|
.map((c) => (
|
|
<li
|
|
key={c.key}
|
|
className="dropdown-item rounded py-1 cursor-pointer"
|
|
onClick={() => {
|
|
setGroupBy(c.key);
|
|
// groupByKey = c.key;
|
|
}}
|
|
>
|
|
{c.title}
|
|
</li>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="col-4 ">
|
|
<div className="d-flex justify-content-end align-items-center gap-2">
|
|
{features.columnVisibility && (
|
|
<ColumnVisibilityPanel
|
|
columns={colState}
|
|
onToggle={(k) =>
|
|
updateColumn(k, {
|
|
visible: !colState.find((c) => c.key === k).visible,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
<i
|
|
className={`bx bx-${
|
|
isFullScreen ? "exit-fullscreen" : "fullscreen"
|
|
} cursor-pointer`}
|
|
onClick={() => setFullScreen(!isFullScreen)}
|
|
></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="d-flex">
|
|
<FilterApplied
|
|
groupBy={groupBy}
|
|
removeGroupby={() => setGroupBy(null)}
|
|
advance={advanceFilters}
|
|
/>
|
|
</div>
|
|
{/* Table-Start*/}
|
|
<div
|
|
ref={wrapperRef}
|
|
className="grid-wrapper text-nowrap border"
|
|
style={{ maxHeight: features.maxHeight || "80vh" }}
|
|
>
|
|
<table
|
|
className="table table-sm rounded mb-0 "
|
|
style={{ width: "max-content", minWidth: "100%" }}
|
|
>
|
|
<thead
|
|
className="bg-light-secondary border p-2 bg-light rounded"
|
|
style={{ position: "sticky", top: 0, zIndex: 10 }}
|
|
>
|
|
<tr className="p-2">
|
|
{features.IsNumbering && <th>#</th>}
|
|
{features.expand && (
|
|
<th className="text-center ticky-action-column">
|
|
<i className="bx bx-collapse-vertical"></i>
|
|
</th>
|
|
)}
|
|
{features.selection && (
|
|
<th
|
|
style={{ width: 32, position: "sticky", top: 0, zIndex: 10 }}
|
|
className="text-center ticky-action-column"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input mx-3"
|
|
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 vs-th ${
|
|
col.pinned ? `pinned pinned-${col.pinned}` : ""
|
|
}`}
|
|
style={style}
|
|
>
|
|
<div
|
|
className={`d-flex align-items-center justify-content-between px-1 ${
|
|
col.pinned ? "z-6" : ""
|
|
}`}
|
|
>
|
|
<div
|
|
onClick={() => col.sortable && changeSort(col.key)}
|
|
style={{ cursor: col.sortable ? "pointer" : "default" }}
|
|
>
|
|
<strong>{col.title}</strong>
|
|
{sortBy.key === col.key && (
|
|
<i
|
|
className={`bx bx-sm ${
|
|
sortBy.dir === "asc"
|
|
? "bxs-chevron-up"
|
|
: "bxs-chevron-down"
|
|
}`}
|
|
></i>
|
|
)}
|
|
{col.pinned === col.key && <i className="bx bx-x"></i>}
|
|
</div>
|
|
|
|
<div className="d-flex align-items-center gap-1">
|
|
{features.pinning && (
|
|
<PmsHeaderOption
|
|
column={col}
|
|
pinned={col.pinned}
|
|
onPinLeft={() => pinColumn(col.key, "left")}
|
|
onPinRight={() => pinColumn(col.key, "right")}
|
|
onUnpin={() => unpinColumn(col.key)}
|
|
advanceFilters={advanceFilters}
|
|
setColumnAdvanceFilter={setColumnAdvanceFilter}
|
|
/>
|
|
)}
|
|
{features.resizing && (
|
|
<i
|
|
className="resize-handle bx bx-move-horizontal"
|
|
onMouseDown={(e) => onResizeMouseDown(e, col.key)}
|
|
></i>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
|
|
{features.actions && (
|
|
<th
|
|
className="text-center sticky-action-column vs-th bg-white fw-semibold th-lastChild"
|
|
style={{ position: "sticky", right: 0, zIndex: 10 }}
|
|
>
|
|
Action
|
|
</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
{loading || totalRows === 0
|
|
? Array.from({ length: 1 }).map((_, index) => (
|
|
<tr className="">
|
|
<td
|
|
key={index}
|
|
colSpan={
|
|
visibleColumns.length +
|
|
(features.selection ? 1 : 0) +
|
|
(features.actions ? 1 : 0)
|
|
}
|
|
className="text-center py-4 "
|
|
>
|
|
<div className=" d-flex justify-content-center align-items-center vh-50">
|
|
<div>
|
|
{loading ? (
|
|
<p>Loading...</p>
|
|
) : (
|
|
<img
|
|
src="/img/illustrations/NoSearchResult.svg"
|
|
alt="Image"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
: !loading && groupBy && groupedRows && groupedRows.length > 0
|
|
? groupedRows.map((g, indG) => (
|
|
<React.Fragment key={g.key}>
|
|
<tr className="bg-light-primary">
|
|
<td
|
|
colSpan={
|
|
visibleColumns.length +
|
|
(features.selection ? 1 : 0) +
|
|
(features.actions ? 1 : 0)
|
|
}
|
|
className="text-start pinned pinned-left tr-group"
|
|
>
|
|
<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, indG) => renderRow(row, indG))}
|
|
</React.Fragment>
|
|
))
|
|
: currentRows.map((row, ind) => renderRow(row, ind))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/* Table-End */}
|
|
|
|
{features.pagination && (
|
|
<div className="d-flex justify-content-between align-items-center mt-2">
|
|
<div className="d-flex flex-row align-items-center gap-2">
|
|
{features.pageSizeSelector && (
|
|
<select
|
|
className="form-select form-select-sm"
|
|
style={{ width: "80px" }}
|
|
value={pageSize}
|
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
|
>
|
|
{[25, 50, 100].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}{" "}
|
|
<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, ind) {
|
|
const isSelected = selected.has(row[rowKey]);
|
|
const isExpanded = expanded.has(row[rowKey]);
|
|
|
|
return (
|
|
<React.Fragment key={row[rowKey]}>
|
|
<tr>
|
|
{features.IsNumbering && (
|
|
<td className="text-center align-middle p-2">
|
|
<small className="text-secondry">{ind + 1}</small>
|
|
</td>
|
|
)}
|
|
{/* Expand toggle next to selection */}
|
|
{features.expand && (
|
|
<td className="text-center align-middle ">
|
|
<button
|
|
type="button"
|
|
className="btn btn-link p-0 border-none text-secondary"
|
|
onClick={() => toggleExpand(row[rowKey])}
|
|
>
|
|
<i
|
|
className={`bx ${
|
|
isExpanded ? "bxs-chevron-up" : "bxs-chevron-down"
|
|
} bx-sm`}
|
|
></i>
|
|
</button>
|
|
</td>
|
|
)}
|
|
{/* Selection checkbox (always left) */}
|
|
{features.selection && (
|
|
<td className="text-center align-middle p-2">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={isSelected}
|
|
onChange={() => toggleSelect(row[rowKey])}
|
|
/>
|
|
</td>
|
|
)}
|
|
|
|
{/* Data columns */}
|
|
{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}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setActiveCell({
|
|
rowId: row[rowKey],
|
|
columnKey: col.key,
|
|
});
|
|
col.onCellClick && col.onCellClick(row, col);
|
|
}}
|
|
className={`${col.className ?? ""} ${
|
|
col.pinned
|
|
? "pinned-left px-3 bg-white pms-grid td pinned"
|
|
: "px-3"
|
|
} ${
|
|
activeCell?.rowId == row.id &&
|
|
activeCell.columnKey === col.key
|
|
? "grid-cell-active"
|
|
: ""
|
|
} cursor-pointer`}
|
|
>
|
|
{col.render ? col.render(row) : row[col.key] ?? ""}
|
|
</td>
|
|
);
|
|
})}
|
|
|
|
{/* Actions column (always right) */}
|
|
{features.actions && (
|
|
<td
|
|
className="text-center sticky-action-column bg-white td-lastChild"
|
|
style={{
|
|
position: "sticky",
|
|
right: 0,
|
|
zIndex: 2,
|
|
width: "1%",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{isDropdown ? (
|
|
<div className="dropdown z-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon btn-text-secondary rounded-pill dropdown-toggle hide-arrow p-0"
|
|
data-bs-toggle="dropdown"
|
|
aria-expanded="false"
|
|
>
|
|
<i
|
|
className="bx bx-dots-vertical-rounded bx-sm text-muted"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-offset="0,8"
|
|
data-bs-placement="top"
|
|
data-bs-custom-class="tooltip-dark"
|
|
title="More Action"
|
|
></i>
|
|
</button>
|
|
<ul className="dropdown-menu dropdown-menu-end">
|
|
<li>
|
|
<a
|
|
aria-label="click to View details"
|
|
className="dropdown-item"
|
|
>
|
|
<i className="bx bx-detail me-2"></i>
|
|
<span className="align-left">View details</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="d-inline-flex justify-content-center align-items-center gap-2"
|
|
style={{ minWidth: "fit-content", padding: "0 4px" }}
|
|
>
|
|
{Array.isArray(features.actions)
|
|
? features.actions.map((act, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
className="btn btn-link p-0 border-0"
|
|
title={act.label}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
act.onClick && act.onClick(row);
|
|
}}
|
|
>
|
|
<i className={`bx ${act.icon}`} />
|
|
</button>
|
|
))
|
|
: typeof features.actions === "function"
|
|
? features.actions(row, toggleExpand)
|
|
: null}
|
|
</div>
|
|
)}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
|
|
{/* 5. Expanded row content (full width) */}
|
|
{isExpanded && renderExpanded && (
|
|
<tr className="table-active">
|
|
<td
|
|
colSpan={
|
|
visibleColumns.length +
|
|
(features.selection ? 1 : 0) +
|
|
(features.expand ? 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"
|
|
>
|
|
<i className="bx bx-sm me-2 bx-columns "></i> 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>
|
|
);
|
|
}
|
|
// For Full screen - class card card-action card-fullscreen
|