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