Compare commits

...

24 Commits

Author SHA1 Message Date
102a88aee6 changed style 2025-12-05 10:00:14 +05:30
29f297740b added bg color for group row 2025-12-02 12:59:36 +05:30
6a7066c771 removed duplicate class 2025-12-02 11:03:11 +05:30
0f8c70a897 Merge branch 'main' of https://git.marcoaiot.com/admin/marco.pms.web into PmsGrid 2025-12-02 11:02:58 +05:30
27cbd18fa1 added dropdown mode 2025-11-25 10:57:11 +05:30
9dc2f811dd noted full screen class 2025-11-24 17:57:48 +05:30
e1f72828f9 added serverside groupging 2025-11-24 11:22:18 +05:30
ee1cf01743 added filter - sorting, and groupby 2025-11-22 19:17:47 +05:30
b8861fbf41 added server side searching and added numbering 2025-11-22 14:08:30 +05:30
6d72ed4735 added server data 2025-11-21 20:00:36 +05:30
c615678981 separated column pinnd fun 2025-11-21 09:45:35 +05:30
817cfb2731 Merge branch 'main' of https://git.marcoaiot.com/admin/marco.pms.web into PmsGrid 2025-11-20 22:03:06 +05:30
d96a1a88ec initially setup poject infra in PmsGrid 2025-11-09 23:37:22 +05:30
7b0659e820 upgrade pms action cell 2025-11-09 22:39:30 +05:30
96faf07311 changed shadow of pin cells 2025-11-09 22:04:40 +05:30
e2bd654c9c added dropdown in header 2025-11-09 01:07:42 +05:30
d839f631f8 added sytle for table 2025-11-03 00:13:38 +05:30
ab5ae6491d setsize 2025-11-01 19:15:53 +05:30
09b8f8d3d2 set size 2025-11-01 19:15:41 +05:30
e77fbf6d1d update action btn 2025-11-01 02:09:15 +05:30
73f437e911 fixed PmsGrid header 2025-10-31 09:57:28 +05:30
7329319417 added demo data 2025-10-31 00:53:42 +05:30
c594d146cc inital setup 2025-10-31 00:52:18 +05:30
cbe0b188c4 initial setup pmsGrid 2025-10-31 00:51:58 +05:30
22 changed files with 2736 additions and 160 deletions

View File

@ -456,7 +456,7 @@ table {
border-collapse: collapse; border-collapse: collapse;
} }
.tr-group{ .tr-group{
background-color: var(--bs-body-bg); /* apply globale color for table row, where grouping datewise*/ background-color: var(--bs-body-bg) !important; /* apply globale color for table row, where grouping datewise*/
} }
caption { caption {
padding-top: 0.782rem; padding-top: 0.782rem;
@ -481,6 +481,24 @@ th {
border-style: solid; border-style: solid;
border-width: 0; border-width: 0;
} }
.vs-th {
position: relative;
border: none;
background-color: white;
padding: 0.75rem 1rem;
vertical-align: middle;
}
.vs-th::after {
content: "";
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 1px;
background-color: white;
}
label { label {
display: inline-block; display: inline-block;

View File

@ -16497,6 +16497,10 @@ html:not([dir=rtl]) .toast.bs-toast .toast-header .btn-close {
.z-5 { .z-5 {
z-index: 5 !important; z-index: 5 !important;
} }
.z-6 {
z-index: 10 !important;
}
.cursor-pointer { .cursor-pointer {
cursor: pointer !important; cursor: pointer !important;

View File

@ -85,7 +85,8 @@ const ExpenseList = ({ filters, groupBy = "transactionDate", searchText }) => {
displayField = "Status"; displayField = "Status";
break; break;
case "submittedBy": case "submittedBy":
key = `${item?.createdBy?.firstName ?? ""} ${item.createdBy?.lastName ?? "" key = `${item?.createdBy?.firstName ?? ""} ${
item.createdBy?.lastName ?? ""
}`.trim(); }`.trim();
displayField = "Submitted By"; displayField = "Submitted By";
break; break;

View File

@ -0,0 +1,35 @@
import React from "react";
import FloorTable from "./FloorTable";
import { useSelectedProject } from "../../../slices/apiDataManager";
import { useProjectInfra } from "../../../hooks/useProjects";
import { PmsGrid } from "../../../services/pmsGrid";
export default function BuildingTable() {
const project = useSelectedProject();
const { projectInfra } = useProjectInfra(project, null);
const columns = [
{ key: "buildingName", title: "Building", sortable: true,className: "text-start", },
{ key: "plannedWork", title: "Planned Work" },
{ key: "completedWork", title: "Completed Work" },
{ key: "percentage", title: "Completion %" },
];
return (
<PmsGrid
columns={columns}
data={projectInfra}
rowKey="id"
features={{
expand: true,
pinning: true,
resizing: true,
selection: true,
}}
renderExpanded={(building) => <FloorTable floors={building.floors} />}
/>
);
}

View File

@ -0,0 +1,28 @@
import React from "react";
import WorkAreaTable from "./WorkAreaTable";
import { PmsGrid } from "../../../services/pmsGrid";
export default function FloorTable({ floors }) {
const columns = [
{ key: "floorName", title: "Floor", sortable: true, },
{ key: "plannedWork", title: "Planned Work" },
{ key: "completedWork", title: "Completed Work" },
{ key: "percentage", title: "Completion %" },
];
return (
<PmsGrid
columns={columns}
data={floors}
rowKey="id"
features={{
expand: true,
pinning: true,
resizing: true,
}}
renderExpanded={(floor) => (
<WorkAreaTable workAreas={floor.workAreas} />
)}
/>
);
}

View File

@ -0,0 +1,39 @@
import React, { useState, useEffect } from "react";
import { PmsGrid } from "../../../services/pmsGrid";
// import { GridService } from "./gridService";
// import TaskTable from "./TaskTable";
export default function WorkAreaTable({ workAreas }) {
const [taskData, setTaskData] = useState({}); // workAreaId tasks
const columns = [
{ key: "areaName", title: "Work Area", },
{ key: "plannedWork", title: "Planned Work" },
{ key: "completedWork", title: "Completed Work" },
{ key: "percentage", title: "Completion %" },
];
// const loadTasks = async (workAreaId) => {
// if (!taskData[workAreaId]) {
// const res = await GridService.getTasksByWorkArea(workAreaId);
// setTaskData((prev) => ({ ...prev, [workAreaId]: res }));
// }
// };
return (
<PmsGrid
columns={columns}
data={workAreas}
rowKey="id"
features={{
expand: true,
pinning: true,
resizing: true,
}}
// renderExpanded={(area) => {
// loadTasks(area.id);
// return <TaskTable tasks={taskData[area.id]} />;
// }}
/>
);
}

View File

@ -15,6 +15,8 @@ import { useCollectionContext } from "../../pages/collections/CollectionPage";
import { CollectionTableSkeleton } from "./CollectionSkeleton"; import { CollectionTableSkeleton } from "./CollectionSkeleton";
import { useSelectedProject } from "../../slices/apiDataManager"; import { useSelectedProject } from "../../slices/apiDataManager";
import { useHasUserPermission } from "../../hooks/useHasUserPermission"; import { useHasUserPermission } from "../../hooks/useHasUserPermission";
import { PmsGrid } from "../../services/pmsGrid";
import PmGridCollection from "./PmGridCollection";
const CollectionList = ({ fromDate, toDate, isPending, searchString }) => { const CollectionList = ({ fromDate, toDate, isPending, searchString }) => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@ -28,16 +30,16 @@ const CollectionList = ({ fromDate, toDate, isPending, searchString }) => {
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const searchDebounce = useDebounce(searchString, 500); const searchDebounce = useDebounce(searchString, 500);
const { data, isLoading, isError, error } = useCollections( // const { data, isLoading, isError, error } = useCollections(
selectedProject, // selectedProject,
searchDebounce, // searchDebounce,
localToUtc(fromDate), // localToUtc(fromDate),
localToUtc(toDate), // localToUtc(toDate),
ITEMS_PER_PAGE, // ITEMS_PER_PAGE,
currentPage, // currentPage,
true, // true,
isPending // isPending
); // );
const { setProcessedPayment, setAddPayment, setViewCollection } = const { setProcessedPayment, setAddPayment, setViewCollection } =
useCollectionContext(); useCollectionContext();
const paginate = (page) => { const paginate = (page) => {
@ -157,148 +159,151 @@ const CollectionList = ({ fromDate, toDate, isPending, searchString }) => {
}, },
]; ];
if (isLoading) return <CollectionTableSkeleton />; // if (isLoading) return <CollectionTableSkeleton />;
if (isError) return <p>{error.message}</p>; // if (isError) return <p>{error.message}</p>;
return ( return (
<div className="card px-sm-4 px-0"> // <div className="card px-sm-4 px-0">
<div // <div
className="card-datatable table-responsive page-min-h" // className="card-datatable table-responsive page-min-h"
id="horizontal-example" // id="horizontal-example"
> // >
<div className="dataTables_wrapper no-footer mx-3 pb-2"> // <div className="dataTables_wrapper no-footer mx-3 pb-2">
<table className="table dataTable text-nowrap"> // <table className="table dataTable text-nowrap">
<thead> // <thead>
<tr className="table_header_border"> // <tr className="table_header_border">
{collectionColumns.map((col) => ( // {collectionColumns.map((col) => (
<th key={col.key} className={col.align}> // <th key={col.key} className={col.align}>
{col.label} // {col.label}
</th> // </th>
))} // ))}
{(isAdmin || // {(isAdmin ||
canAddPayment || // canAddPayment ||
canViewCollection || // canViewCollection ||
canEditCollection || // canEditCollection ||
canCreate) && <th>Action</th>} // canCreate) && <th>Action</th>}
</tr> // </tr>
</thead> // </thead>
<tbody> // <tbody>
{Array.isArray(data?.data) && data.data.length > 0 ? ( // {Array.isArray(data?.data) && data.data.length > 0 ? (
data.data.map((row, i) => ( // data.data.map((row, i) => (
<tr key={i}> // <tr key={i}>
{collectionColumns.map((col) => ( // {collectionColumns.map((col) => (
<td key={col.key} className={col.align}> // <td key={col.key} className={col.align}>
{col.getValue(row)} // {col.getValue(row)}
</td> // </td>
))} // ))}
{(isAdmin || // {(isAdmin ||
canAddPayment || // canAddPayment ||
canViewCollection || // canViewCollection ||
canEditCollection || // canEditCollection ||
canCreate) && ( // canCreate) && (
<td // <td
className="sticky-action-column text-center" // className="sticky-action-column text-center"
style={{ padding: "12px 8px" }} // style={{ padding: "12px 8px" }}
> // >
<div className="dropdown z-2"> // <div className="dropdown z-2">
<button // <button
type="button" // type="button"
className="btn btn-icon btn-text-secondary rounded-pill dropdown-toggle hide-arrow p-0" // className="btn btn-icon btn-text-secondary rounded-pill dropdown-toggle hide-arrow p-0"
data-bs-toggle="dropdown" // data-bs-toggle="dropdown"
aria-expanded="false" // aria-expanded="false"
> // >
<i // <i
className="bx bx-dots-vertical-rounded bx-sm text-muted" // className="bx bx-dots-vertical-rounded bx-sm text-muted"
data-bs-toggle="tooltip" // data-bs-toggle="tooltip"
data-bs-offset="0,8" // data-bs-offset="0,8"
data-bs-placement="top" // data-bs-placement="top"
data-bs-custom-class="tooltip-dark" // data-bs-custom-class="tooltip-dark"
title="More Action" // title="More Action"
></i> // ></i>
</button> // </button>
<ul className="dropdown-menu dropdown-menu-end"> // <ul className="dropdown-menu dropdown-menu-end">
{/* View */} // {/* View */}
<li> // <li>
<a // <a
className="dropdown-item cursor-pointer" // className="dropdown-item cursor-pointer"
onClick={() => setViewCollection(row.id)} // onClick={() => setViewCollection(row.id)}
> // >
<i className="bx bx-show me-2 text-primary"></i> // <i className="bx bx-show me-2 text-primary"></i>
<span>View</span> // <span>View</span>
</a> // </a>
</li> // </li>
{/* Only if not completed */} // {/* Only if not completed */}
{!row?.markAsCompleted && ( // {!row?.markAsCompleted && (
<> // <>
{/* Add Payment */} // {/* Add Payment */}
{(isAdmin || canAddPayment) && ( // {(isAdmin || canAddPayment) && (
<li> // <li>
<a // <a
className="dropdown-item cursor-pointer" // className="dropdown-item cursor-pointer"
onClick={() => // onClick={() =>
setAddPayment({ // setAddPayment({
isOpen: true, // isOpen: true,
invoiceId: row.id, // invoiceId: row.id,
}) // })
} // }
> // >
<i className="bx bx-wallet me-2 text-warning"></i> // <i className="bx bx-wallet me-2 text-warning"></i>
<span>Add Payment</span> // <span>Add Payment</span>
</a> // </a>
</li> // </li>
)} // )}
{/* Mark Payment */} // {/* Mark Payment */}
{isAdmin && ( // {isAdmin && (
<li> // <li>
<a // <a
className="dropdown-item cursor-pointer" // className="dropdown-item cursor-pointer"
onClick={() => // onClick={() =>
setProcessedPayment({ // setProcessedPayment({
isOpen: true, // isOpen: true,
invoiceId: row.id, // invoiceId: row.id,
}) // })
} // }
> // >
<i className="bx bx-check-circle me-2 text-success"></i> // <i className="bx bx-check-circle me-2 text-success"></i>
<span>Mark Payment</span> // <span>Mark Payment</span>
</a> // </a>
</li> // </li>
)} // )}
</> // </>
)} // )}
</ul> // </ul>
</div> // </div>
</td> // </td>
)} // )}
</tr> // </tr>
)) // ))
) : ( // ) : (
<tr style={{ height: "200px" }}> // <tr style={{ height: "200px" }}>
<td // <td
colSpan={collectionColumns.length + 1} // colSpan={collectionColumns.length + 1}
className="text-center border-0 align-middle" // className="text-center border-0 align-middle"
> // >
No Collections Found // No Collections Found
</td> // </td>
</tr> // </tr>
)} // )}
</tbody> // </tbody>
</table> // </table>
{data?.data?.length > 0 && ( // {data?.data?.length > 0 && (
<div className="d-flex justify-content-start mt-2"> // <div className="d-flex justify-content-start mt-2">
<Pagination // <Pagination
currentPage={currentPage} // currentPage={currentPage}
totalPages={data?.totalPages} // totalPages={data?.totalPages}
onPageChange={paginate} // onPageChange={paginate}
/> // />
</div> // </div>
)} // )}
</div> // </div>
</div> // </div>
// </div>
<div className="card p-2">
<PmGridCollection selectedProject={selectedProject} fromDate={localToUtc(fromDate)} toDate={localToUtc(toDate)} />
</div> </div>
); );
}; };

View File

@ -0,0 +1,121 @@
import React from "react";
import { PmsGrid } from "../../services/pmsGrid";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import { formatFigure } from "../../utils/appUtils";
import { CollectionRepository } from "../../repositories/ColllectionRepository";
const PmGridCollection = ({ selectedProject, fromDate, toDate, isPending }) => {
const columns = [
{
key: "invoiceNumber",
title: "Invoice Number",
className: "text-start",
groupable: true,
},
{ key: "title", title: "Title", sortable: true, className: "text-start" },
{
key: "clientSubmitedDate",
title: "Submission Date",
className: "text-start",
groupable: true,
},
{
key: "exceptedPaymentDate",
title: "Expected Payment Date",
className: "text-start",
},
{ key: "totalAmount", title: "Total Amount" },
{ key: "balanceAmount", title: "Balance" },
{ key: "isActive", title: "Status" },
];
const fetcher = async ({ page, pageSize, search, filter }) => {
const response = await CollectionRepository.getCollections(
selectedProject,
search || "",
fromDate,
toDate,
pageSize,
page,
true, // isActive
isPending,
filter
);
const api = response.data;
return {
rows: api.data.map((item) => ({
id: item.id,
invoiceNumber: item.invoiceNumber,
title: item.title,
clientSubmitedDate: formatUTCToLocalTime(item.clientSubmitedDate),
exceptedPaymentDate: formatUTCToLocalTime(item.exceptedPaymentDate),
totalAmount: formatFigure(item.basicAmount + item.taxAmount, {
type: "currency",
currency: "INR",
}),
balanceAmount: formatFigure(item.balanceAmount, {
type: "currency",
currency: "INR",
}),
isActive: item.isActive ? (
<span className="badge bg-label-primary">
<span className="badge badge-dot bg-primary me-1"></span>
Active
</span>
) : (
<span className="badge bg-label-danger">
<span className="badge badge-dot bg-danger me-1"></span>
In-Active
</span>
),
})),
total: api.totalEntities,
};
};
return (
<PmsGrid
columns={columns}
serverMode
fetcher={fetcher}
rowKey="id"
features={{
search: true,
pagination: true,
pinning: true,
resizing: true,
selection: false,
reorder: true,
columnVisibility: true,
pageSizeSelector: true,
// groupByKey: "clientSubmitedDate",
aggregation: true,
pinning: true,
IsNumbering: true,
grouping: true,
actions: [
{
label: "Edit",
icon: "bx-edit ",
onClick: (row) => console.log("Edit", row),
},
{
label: "Delete",
icon: "bx-trash text-danger",
onClick: (row) => console.log("Delete", row),
},
]
}}
/>
);
};
export default PmGridCollection;

View File

@ -50,7 +50,6 @@ const InputSuggestions = ({
{filteredList.map((org) => ( {filteredList.map((org) => (
<li <li
key={org} key={org}
className="ropdown-item"
style={{ style={{
cursor: "pointer", cursor: "pointer",
padding: "5px 12px", padding: "5px 12px",

View File

@ -24,6 +24,7 @@ import { useProjectAccess } from "../../hooks/useProjectAccess";
import "./ProjectDetails.css"; import "./ProjectDetails.css";
import ProjectOrganizations from "../../components/Project/ProjectOrganizations"; import ProjectOrganizations from "../../components/Project/ProjectOrganizations";
import BuildingTable from "../../components/Project/pmsInfrastructure/BuildingTable";
const ProjectDetails = () => { const ProjectDetails = () => {
const projectId = useSelectedProject(); const projectId = useSelectedProject();
@ -95,7 +96,7 @@ const ProjectDetails = () => {
case "teams": case "teams":
return <Teams />; return <Teams />;
case "infra": case "infra":
return <ProjectInfra data={projects_Details} onDataChange={refetch} />; return <BuildingTable/>
case "workplan": case "workplan":
return <WorkPlan data={projects_Details} onDataChange={refetch} />; return <WorkPlan data={projects_Details} onDataChange={refetch} />;
case "directory": case "directory":

View File

@ -5,7 +5,7 @@ export const CollectionRepository = {
createNewCollection: (data) => createNewCollection: (data) =>
api.post(`/api/Collection/invoice/create`, data), api.post(`/api/Collection/invoice/create`, data),
updateCollection: (id, data) => { updateCollection: (id, data) => {
api.put(`/api/Collection/invoice/edit/${id}`, data) api.put(`/api/Collection/invoice/edit/${id}`, data);
}, },
// getCollections: (pageSize, pageNumber,fromDate,toDate, isPending,isActive,projectId, searchString) => { // getCollections: (pageSize, pageNumber,fromDate,toDate, isPending,isActive,projectId, searchString) => {
// let url = `/api/Collection/invoice/list?pageSize=${pageSize}&pageNumber=${pageNumber}&isPending=${isPending}&isActive=${isActive}&searchString=${searchString}`; // let url = `/api/Collection/invoice/list?pageSize=${pageSize}&pageNumber=${pageNumber}&isPending=${isPending}&isActive=${isActive}&searchString=${searchString}`;
@ -21,7 +21,17 @@ export const CollectionRepository = {
// return api.get(url); // return api.get(url);
// }, // },
getCollections: (projectId, searchString, fromDate, toDate, pageSize, pageNumber, isActive, isPending) => { getCollections: (
projectId,
searchString,
fromDate,
toDate,
pageSize,
pageNumber,
isActive,
isPending,
filter
) => {
let url = `/api/Collection/invoice/list`; let url = `/api/Collection/invoice/list`;
const params = []; const params = [];
@ -33,6 +43,7 @@ export const CollectionRepository = {
if (pageNumber) params.push(`pageNumber=${pageNumber}`); if (pageNumber) params.push(`pageNumber=${pageNumber}`);
if (isActive) params.push(`isActive=${isActive}`); if (isActive) params.push(`isActive=${isActive}`);
if (isPending) params.push(`isPending=${isPending}`); if (isPending) params.push(`isPending=${isPending}`);
if (filter) params.push(`filter=${filter}`);
if (params.length > 0) { if (params.length > 0) {
url += "?" + params.join("&"); url += "?" + params.join("&");
@ -41,9 +52,10 @@ export const CollectionRepository = {
return api.get(url); return api.get(url);
}, },
makeReceivePayment: (data) => api.post(`/api/Collection/invoice/payment/received`, data), makeReceivePayment: (data) =>
markPaymentReceived: (invoiceId) => api.put(`/api/Collection/invoice/marked/completed/${invoiceId}`), api.post(`/api/Collection/invoice/payment/received`, data),
markPaymentReceived: (invoiceId) =>
api.put(`/api/Collection/invoice/marked/completed/${invoiceId}`),
getCollection: (id) => api.get(`/api/Collection/invoice/details/${id}`), getCollection: (id) => api.get(`/api/Collection/invoice/details/${id}`),
addComment: (data) => api.post(`/api/Collection/invoice/add/comment`, data) addComment: (data) => api.post(`/api/Collection/invoice/add/comment`, data),
}; };

View File

@ -62,6 +62,7 @@ import AdvancePaymentPage from "../pages/AdvancePayment/AdvancePaymentPage";
import ServiceProjectDetail from "../pages/ServiceProject/ServiceProjectDetail"; import ServiceProjectDetail from "../pages/ServiceProject/ServiceProjectDetail";
import ManageJob from "../components/ServiceProject/ServiceProjectJob/ManageJob"; import ManageJob from "../components/ServiceProject/ServiceProjectJob/ManageJob";
import AdvancePaymentPage1 from "../pages/AdvancePayment/AdvancePaymentPage1"; import AdvancePaymentPage1 from "../pages/AdvancePayment/AdvancePaymentPage1";
import DemoBOQGrid from "../services/pmsGrid/BasicTable";
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
{ {
@ -81,6 +82,8 @@ const router = createBrowserRouter(
], ],
}, },
{ path: "/auth/switch/org", element: <TenantSelectionPage /> }, { path: "/auth/switch/org", element: <TenantSelectionPage /> },
{ path: "/help/docs", element: <DemoBOQGrid /> },
{ {
path: "/auth/subscripe/:frequency/:planId", path: "/auth/subscripe/:frequency/:planId",
element: <MakeSubscription />, element: <MakeSubscription />,
@ -127,8 +130,7 @@ const router = createBrowserRouter(
{ path: "/tenant/self", element: <SelfTenantDetails /> }, { path: "/tenant/self", element: <SelfTenantDetails /> },
{ path: "/organizations", element: <OrganizationPage /> }, { path: "/organizations", element: <OrganizationPage /> },
{ path: "/help/support", element: <Support /> }, { path: "/help/support", element: <Support /> },
{ path: "/help/docs", element: <Documentation /> },
{ path: "/help/connect", element: <Connect /> },
], ],
}, },
], ],

View File

@ -0,0 +1,811 @@
import React, { useEffect, useRef } from "react";
import { PmsGrid } from "./index";
import { initPopover } from "./GridService";
import PmsHeaderOption from "./PmsHeaderOption";
/**
* CIVIL BOQ / INVENTORY DEMO DATA
* Each row = BOQ item, grouped by "Category"
*/
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: "Masonry Works",
itemCode: "M-002",
description: "Block Masonry AAC 200mm wall",
unit: "Sqm",
quantity: 240,
rate: 1100,
site: "Green City",
vendor: "Ambuja Blocks",
status: "Pending",
},
{
id: 7,
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",
},
{
id: 8,
category: "Plastering Works",
itemCode: "P-002",
description: "External Plaster 20mm thick",
unit: "Sqm",
quantity: 800,
rate: 320,
site: "Sunshine Plaza",
vendor: "Hindustan Plaster",
status: "In Progress",
},
{
id: 9,
category: "Flooring Works",
itemCode: "F-001",
description: "Vitrified Tiles 600x600mm",
unit: "Sqm",
quantity: 600,
rate: 650,
site: "Green City - Clubhouse",
vendor: "Kajaria",
status: "Pending",
},
{
id: 10,
category: "Flooring Works",
itemCode: "F-002",
description: "Granite Flooring 20mm thick",
unit: "Sqm",
quantity: 300,
rate: 1250,
site: "Skyline Heights - Lobby",
vendor: "Classic Stone",
status: "Completed",
},
{
id: 11,
category: "Electrical Works",
itemCode: "E-001",
description: "PVC Conduit 25mm dia",
unit: "Mtr",
quantity: 1500,
rate: 45,
site: "Eco Towers",
vendor: "Finolex",
status: "In Progress",
},
{
id: 12,
category: "Electrical Works",
itemCode: "E-002",
description: "Copper Wire 4 sqmm",
unit: "Mtr",
quantity: 2000,
rate: 65,
site: "Skyline Heights",
vendor: "Polycab",
status: "Completed",
},
{
id: 13,
category: "Plumbing Works",
itemCode: "PL-001",
description: "CPVC Pipe 25mm dia",
unit: "Mtr",
quantity: 1000,
rate: 120,
site: "Green City - Tower C",
vendor: "Astral Pipes",
status: "Pending",
},
{
id: 14,
category: "Plumbing Works",
itemCode: "PL-002",
description: "UPVC Pipe 40mm dia",
unit: "Mtr",
quantity: 800,
rate: 100,
site: "Sunshine Plaza",
vendor: "Supreme Industries",
status: "In Progress",
},
{
id: 15,
category: "Painting Works",
itemCode: "PA-001",
description: "Interior Emulsion Paint",
unit: "Ltr",
quantity: 500,
rate: 180,
site: "Skyline Heights",
vendor: "Asian Paints",
status: "Completed",
},
{
id: 16,
category: "Painting Works",
itemCode: "PA-002",
description: "Exterior Weatherproof Paint",
unit: "Ltr",
quantity: 400,
rate: 220,
site: "Green City - Tower D",
vendor: "Berger Paints",
status: "Pending",
},
{
id: 17,
category: "Waterproofing Works",
itemCode: "W-001",
description: "Cementitious Coating 2 layers",
unit: "Sqm",
quantity: 350,
rate: 480,
site: "Sunshine Plaza",
vendor: "Dr. Fixit",
status: "Completed",
},
{
id: 18,
category: "Waterproofing Works",
itemCode: "W-002",
description: "Polyurethane Waterproofing Membrane Polyurethane Waterproofing Membrane",
unit: "Sqm",
quantity: 200,
rate: 850,
site: "Green City - Podium",
vendor: "Pidilite",
status: "In Progress",
},
{
id: 19,
category: "HVAC Works",
itemCode: "H-001",
description: "Ducting for AHU system",
unit: "Sqm",
quantity: 250,
rate: 2100,
site: "Skyline Heights - Basement",
vendor: "Blue Star",
status: "Pending",
},
{
id: 20,
category: "HVAC Works",
itemCode: "H-002",
description: "VRF Indoor Unit Installation",
unit: "Nos",
quantity: 30,
rate: 42000,
site: "Eco Towers",
vendor: "Daikin",
status: "Completed",
},
{
id: 21,
category: "Plumbing Works",
itemCode: "PL-003",
description: "CP Fittings - Wash Basin Mixer",
unit: "Nos",
quantity: 50,
rate: 950,
site: "Skyline Heights",
vendor: "Jaquar",
status: "Completed",
},
{
id: 22,
category: "Electrical Works",
itemCode: "E-003",
description: "LED Downlight 12W",
unit: "Nos",
quantity: 120,
rate: 750,
site: "Green City - Tower B",
vendor: "Philips",
status: "Pending",
},
{
id: 23,
category: "Flooring Works",
itemCode: "F-003",
description: "Wooden Flooring 12mm laminate",
unit: "Sqm",
quantity: 150,
rate: 1450,
site: "Sunshine Plaza - Office",
vendor: "Pergo",
status: "In Progress",
},
{
id: 24,
category: "Concrete Works",
itemCode: "C-003",
description: "PCC 1:4:8 flooring",
unit: "Cum",
quantity: 60,
rate: 4800,
site: "Eco Towers",
vendor: "RMC Readymix",
status: "Completed",
},
{
id: 25,
category: "Masonry Works",
itemCode: "M-003",
description: "Stone masonry in foundation",
unit: "Cum",
quantity: 45,
rate: 3500,
site: "Green City - Tower C",
vendor: "Shree Bricks",
status: "In Progress",
},
{
id: 26,
category: "Carpentry Works",
itemCode: "CP-001",
description: "Flush Door with Laminate Finish",
unit: "Nos",
quantity: 80,
rate: 6500,
site: "Skyline Heights",
vendor: "Greenply",
status: "Completed",
},
{
id: 27,
category: "Carpentry Works",
itemCode: "CP-002",
description: "Modular Kitchen Cabinets",
unit: "Set",
quantity: 25,
rate: 42000,
site: "Sunshine Plaza",
vendor: "Godrej Interio",
status: "Pending",
},
{
id: 28,
category: "Glazing Works",
itemCode: "G-001",
description: "Aluminium Window Frame with Glass",
unit: "Sqm",
quantity: 180,
rate: 1850,
site: "Green City - Tower D",
vendor: "Saint Gobain",
status: "In Progress",
},
{
id: 29,
category: "Glazing Works",
itemCode: "G-002",
description: "Toughened Glass Door 12mm",
unit: "Sqm",
quantity: 60,
rate: 2750,
site: "Skyline Heights - Lobby",
vendor: "Modiguard",
status: "Completed",
},
{
id: 30,
category: "External Development",
itemCode: "ED-001",
description: "Paver Block 60mm thick",
unit: "Sqm",
quantity: 950,
rate: 380,
site: "Sunshine Plaza",
vendor: "Ultra Pavers",
status: "Pending",
},
{
id: 31,
category: "External Development",
itemCode: "ED-002",
description: "Kerb Stone 300x300mm",
unit: "Rm",
quantity: 200,
rate: 240,
site: "Green City - Parking Area",
vendor: "L&T Infra",
status: "Completed",
},
{
id: 32,
category: "Electrical Works",
itemCode: "E-004",
description: "Distribution Board with MCB",
unit: "Nos",
quantity: 25,
rate: 6200,
site: "Skyline Heights",
vendor: "Legrand",
status: "In Progress",
},
{
id: 33,
category: "Plastering Works",
itemCode: "P-003",
description: "Ceiling POP Finish",
unit: "Sqm",
quantity: 950,
rate: 210,
site: "Green City - Tower A",
vendor: "L&T Finishes",
status: "Completed",
},
{
id: 34,
category: "Painting Works",
itemCode: "PA-003",
description: "Metal Primer + Enamel Paint",
unit: "Ltr",
quantity: 350,
rate: 160,
site: "Sunshine Plaza - Basement",
vendor: "Nerolac Paints",
status: "In Progress",
},
{
id: 35,
category: "HVAC Works",
itemCode: "H-003",
description: "Duct Insulation 50mm thick",
unit: "Sqm",
quantity: 120,
rate: 540,
site: "Eco Towers",
vendor: "Blue Star",
status: "Pending",
},
{
id: 36,
category: "Plumbing Works",
itemCode: "PL-004",
description: "Overhead Water Tank Installation",
unit: "Nos",
quantity: 3,
rate: 45000,
site: "Green City - Tower B",
vendor: "Sintex",
status: "Completed",
},
{
id: 37,
category: "Electrical Works",
itemCode: "E-005",
description: "Earthing Pit with GI Plate",
unit: "Nos",
quantity: 6,
rate: 9500,
site: "Skyline Heights",
vendor: "KEI",
status: "Completed",
},
{
id: 38,
category: "Concrete Works",
itemCode: "C-004",
description: "M30 Concrete for beams",
unit: "Cum",
quantity: 95,
rate: 5800,
site: "Eco Towers - Basement",
vendor: "RMC Readymix",
status: "In Progress",
},
{
id: 39,
category: "Masonry Works",
itemCode: "M-004",
description: "Cement Mortar 1:4 plaster backing",
unit: "Sqm",
quantity: 700,
rate: 180,
site: "Skyline Heights - Wing E",
vendor: "Ambuja Blocks",
status: "Completed",
},
{
id: 40,
category: "Flooring Works",
itemCode: "F-004",
description: "Marble Flooring 20mm thick",
unit: "Sqm",
quantity: 260,
rate: 2100,
site: "Green City - Tower D",
vendor: "Classic Stone",
status: "Pending",
},
{
id: 41,
category: "Carpentry Works",
itemCode: "CP-003",
description: "Wardrobe with Laminate Finish",
unit: "Nos",
quantity: 40,
rate: 18500,
site: "Sunshine Plaza",
vendor: "Durian",
status: "Completed",
},
{
id: 42,
category: "Glazing Works",
itemCode: "G-003",
description: "Spider Glass Façade System",
unit: "Sqm",
quantity: 150,
rate: 3550,
site: "Eco Towers",
vendor: "Saint Gobain",
status: "In Progress",
},
{
id: 43,
category: "Waterproofing Works",
itemCode: "W-003",
description: "Bituminous Membrane Layer 3mm",
unit: "Sqm",
quantity: 400,
rate: 620,
site: "Skyline Heights - Terrace",
vendor: "Dr. Fixit",
status: "Completed",
},
{
id: 44,
category: "External Development",
itemCode: "ED-003",
description: "RCC Drain Construction",
unit: "Rm",
quantity: 180,
rate: 950,
site: "Green City - Service Road",
vendor: "L&T Infra",
status: "In Progress",
},
{
id: 45,
category: "Electrical Works",
itemCode: "E-006",
description: "Street Light Pole 9m",
unit: "Nos",
quantity: 25,
rate: 18500,
site: "Sunshine Plaza - Entry Road",
vendor: "Polycab",
status: "Pending",
},
{
id: 46,
category: "Painting Works",
itemCode: "PA-004",
description: "Texture Finish Coating",
unit: "Sqm",
quantity: 320,
rate: 480,
site: "Green City - Clubhouse",
vendor: "Asian Paints",
status: "In Progress",
},
{
id: 47,
category: "Flooring Works",
itemCode: "F-005",
description: "Epoxy Flooring 3mm thick",
unit: "Sqm",
quantity: 150,
rate: 850,
site: "Skyline Heights - Basement",
vendor: "Pidilite",
status: "Completed",
},
{
id: 48,
category: "Plumbing Works",
itemCode: "PL-005",
description: "Bathroom CP fittings set",
unit: "Set",
quantity: 40,
rate: 5400,
site: "Eco Towers",
vendor: "Hindware",
status: "Completed",
},
{
id: 49,
category: "Concrete Works",
itemCode: "C-005",
description: "Ready Mix M40 Concrete",
unit: "Cum",
quantity: 60,
rate: 6100,
site: "Green City - Tower E",
vendor: "ACC",
status: "Pending",
},
{
id: 50,
category: "External Development",
itemCode: "ED-004",
description: "Compound Wall RCC Precast",
unit: "Rm",
quantity: 300,
rate: 1750,
site: "Sunshine Plaza - Boundary",
vendor: "UltraTech",
status: "Completed",
},
];
/**
* COLUMN DEFINITIONS
*/
const boqColumns = [
{
key: "itemCode",
title: "Item Code",
sortable: true,
pinned: "left",
className: "text-start",
},
{
key: "description",
title: "Description",
sortable: true,
width: 300,
className: "text-start",
},
{
key: "category",
title: "Category",
sortable: true,
className: "text-start",
},
{ key: "unit", title: "Unit", width: 80, className: "text-ceter" },
{
key: "quantity",
title: "Qty",
sortable: true,
width: 100,
className: "text-cnter px-2",
},
{
key: "rate",
title: "Rate (₹)",
sortable: true,
width: 120,
className: "text-end",
render: (r) => <span>{r.rate.toLocaleString()}</span>,
},
{
key: "amount",
title: "Amount (₹)",
sortable: true,
width: 130,
className: "text-end",
render: (r) => (
<span className="fw-semibold text-end px-2 py-12">
{(r.quantity * r.rate).toLocaleString()}
</span>
),
aggregate: (vals) =>
"₹" + vals.reduce((sum, val) => sum + val, 0).toLocaleString(),
},
{
key: "vendor",
title: "Vendor",
sortable: true,
width: 180,
className: "text-start",
},
{
key: "site",
title: "Site Location",
sortable: true,
width: 200,
className: "text-start",
},
{
key: "status",
title: "Status",
sortable: true,
width: 120,
className: "text-center",
render: (r) => (
<span
className={`badge bg-label-${
r.status === "Completed"
? "success"
: r.status === "Pending"
? "warning"
: "info"
}`}
>
{r.status}
</span>
),
},
];
/**
* DEMO COMPONENT
*/
export default function DemoBOQGrid() {
useEffect(() => {
initPopover();
}, []);
const wrapperRef = useRef();
useEffect(() => {
if (!wrapperRef.current) return;
const ps = new PerfectScrollbar(wrapperRef.current, {
wheelPropagation: false,
suppressScrollX: false,
});
return () => ps.destroy();
}, []);
return (
<div className="container-fluid py-3">
<div className="card p-3">
<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,
pageSizeSelector: true,
groupByKey: "status",
aggregation: true,
expand: true,
maxHeight: "70vh",
actions: [
{
label: "Edit",
icon: "bx-edit ",
onClick: (row) => console.log("Edit", row),
},
{
label: "Delete",
icon: "bx-trash text-danger",
onClick: (row) => console.log("Delete", row),
},
],
}}
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 Location:</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>
);
}

View File

@ -0,0 +1,40 @@
// GridPinnedCalculator.js
export function computeLeftOffsets(colState) {
const ordered = colState
.filter((c) => c.visible)
.sort((a, b) => a.order - b.order);
let offset = 0;
const map = {};
for (const col of ordered) {
if (col.pinned === "left") {
map[col.key] = offset;
offset += col.width || 120;
} else {
map[col.key] = null; // not pinned-left
}
}
return map;
}
export function computeRightOffsets(colState) {
const ordered = colState
.filter((c) => c.visible)
.sort((a, b) => b.order - a.order);
let offset = 0;
const map = {};
for (const col of ordered) {
if (col.pinned === "right") {
map[col.key] = offset;
offset += col.width || 120;
} else {
map[col.key] = null; // not pinned-right
}
}
return map;
}

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,653 @@
import React, { useRef } from "react";
import { useGridCore } from "./useGridCore";
import { exportToCSV } from "./utils";
import "./pms-grid.css";
import PmsHeaderOption from "./PmsHeaderOption";
/*
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",
isDropdown = false,
features = {},
renderExpanded,
}) {
const grid = useGridCore({
data,
serverMode,
fetcher,
rowKey,
initialPageSize: features.pageSize || 25,
columns,
});
const wrapperRef = useRef();
const {
rows,
page,
totalPages,
pageSize,
setPage,
setPageSize,
setGroupBy,
groupBy,
search,
setSearch,
selected,
toggleSelect,
selectAllOnPage,
deselectAllOnPage,
changeSort,
sortBy,
visibleColumns,
colState,
updateColumn,
loading,
totalRows,
expanded,
toggleExpand,
setColState,
} = 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 ">
<div className="row mb-2 px-4">
<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>
))}
{grid.groupBy && (
<li
className="dropdown-item text-danger py-1 cursor-pointer rounded"
onClick={() => setGroupBy(null)}
>
Clear Grouping
</li>
)}
</div>
</div>
)}
</div>
</div>
<div className="col-4 ">
<div className="d-flex justify-content-end 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>
<div
ref={wrapperRef}
className="grid-wrapper text-nowrap border rounded"
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
pinned={col.pinned}
onPinLeft={() => pinColumn(col.key, "left")}
onPinRight={() => pinColumn(col.key, "right")}
onUnpin={() => unpinColumn(col.key)}
/>
)}
{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 }}
>
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,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>
{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, 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}
className={`${col.className ?? ""} ${
col.pinned
? "pinned-left px-3 bg-white pms-grid td pinned"
: "px-3"
}`}
>
{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

View File

@ -0,0 +1,197 @@
// const PmsHeaderOption = ({ pinned, onPinLeft, onPinRight, onUnpin }) => {
// return (
// <div className="dropdown z-100">
// <button
// type="button"
// className="btn btn-icon btn-text-secondary rounded-pill p-0"
// data-bs-toggle="dropdown"
// data-bs-auto-close="outside"
// aria-expanded="true"
// >
// <i className="bx bx-dots-vertical-rounded bx-sm text-muted"></i>
// </button>
import { useEffect } from "react";
// <ul className="dropdown-menu dropdown-menu-end border shadow rounded-3 py-2">
// <li>
// <button
// className={`dropdown-item d-flex align-items-center ${
// pinned === "left" ? "active" : ""
// }`}
// onClick={onPinLeft}
// >
// <i className="bx bx-pin me-2"></i> Pin Left
// </button>
// </li>
// <li>
// <button
// className={`dropdown-item d-flex align-items-center ${
// pinned === "right" ? "active" : ""
// }`}
// onClick={onPinRight}
// >
// <i className="bx bx-pin me-2"></i> Pin Right
// </button>
// </li>
// {pinned && (
// <>
// <li>
// <hr className="dropdown-divider" />
// </li>
// <li>
// <button
// className="dropdown-item text-danger d-flex align-items-center"
// onClick={onUnpin}
// >
// <i className="bx bx-x me-2"></i> Unpin Column
// </button>
// </li>
// </>
// )}
// </ul>
// </div>
// );
// };
// export default PmsHeaderOption;
const PmsHeaderOption = ({ pinned, onPinLeft, onPinRight, onUnpin }) => {
const toggleSubmenu = (e) => {
e.stopPropagation();
const currentSubMenu = e.currentTarget.nextElementSibling;
if (!currentSubMenu) return;
const allSubMenus = document.querySelectorAll(
".dropdown-submenu > .dropdown-menu"
);
allSubMenus.forEach((menu) => {
if (menu !== currentSubMenu) {
menu.classList.remove("show");
}
});
currentSubMenu.classList.toggle("show");
};
useEffect(() => {
const handleOutsideClick = () => {
const allSubMenus = document.querySelectorAll(
".dropdown-submenu > .dropdown-menu"
);
allSubMenus.forEach((menu) => {
menu.classList.remove("show");
});
};
document.addEventListener("click", handleOutsideClick);
return () => {
document.removeEventListener("click", handleOutsideClick);
};
}, []);
return (
<div className="dropdown" style={{ zIndex: 9999 }}>
<button
type="button"
className="btn btn-icon btn-text-secondary rounded-pill p-0"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
>
<i className="bx bx-dots-vertical-rounded bx-sm text-muted"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end border shadow rounded-3 py-2">
<li className="dropdown-submenu dropstart">
<button
type="button"
className="dropdown-item d-flex align-items-center justify-content-between"
onClick={toggleSubmenu}
>
<span>
<i className="bx bx-left-arrow-alt me-2"></i>
Move Column
</span>
<i className="bx bx-chevron-left"></i>
</button>
<ul className="dropdown-menu border shadow rounded-3 py-2">
<li>
<button
className="dropdown-item d-flex align-items-center"
onClick={() => console.log("Move Left")}
>
<i className="bx bx-arrow-to-left me-2"></i>
Move Left
</button>
</li>
<li>
<button
className="dropdown-item d-flex align-items-center"
onClick={() => console.log("Move Right")}
>
<i className="bx bx-arrow-to-right me-2"></i>
Move Right
</button>
</li>
</ul>
</li>
<li className="dropdown-submenu dropstart">
<button
type="button"
className="dropdown-item d-flex align-items-center justify-content-between"
onClick={toggleSubmenu}
>
<span>
<i className="bx bx-pin me-2"></i> Pin Column
</span>
<i className="bx bx-chevron-right"></i>
</button>
<ul className="dropdown-menu border shadow rounded-3 py-2">
<li>
<button
className="dropdown-item d-flex align-items-center"
onClick={onUnpin}
>
{pinned !== "left" && pinned !== "right" && (
<i className="bx bx-check me-2"></i>
)}
No Pin
</button>
</li>
<li>
<button
className={`dropdown-item d-flex align-items-center ${
pinned === "left" ? "active" : ""
}`}
onClick={onPinLeft}
>
{pinned === "left" && <i className="bx bx-check me-2"></i>}
Pin Left
</button>
</li>
<li className="d-flex flex-row gap-2">
<button
className={`dropdown-item d-flex align-items-center ${
pinned === "right" ? "active" : ""
}`}
onClick={onPinRight}
>
{pinned === "right" && <i className="bx bx-check me-2"></i>}
Pin Right
</button>
</li>
</ul>
</li>
</ul>
</div>
);
};
export default PmsHeaderOption;

View File

@ -0,0 +1,5 @@
export { default as PmsGrid } from "./PmsGrid";
export * from "./useGrid";
export * from "./useGridCore";
export * from "./utils";

View File

@ -0,0 +1,257 @@
/*
PMS GRID MAIN CONTAINER
*/
.pms-grid {
width: 100%;
position: relative;
font-size: 0.875rem;
}
/*
SCROLLABLE WRAPPER
*/
.grid-wrapper {
max-height: 50vh;
overflow-y: auto;
overflow-x: auto;
position: relative;
border: 1px solid #dee2e6;
border-radius: 0.2rem;
background: #fff;
/* Force horizontal scroll even if few columns */
white-space: nowrap;
}
/* Always visible scrollbar (cross browser) */
.grid-wrapper::-webkit-scrollbar {
height: 3px;
width: 3px;
}
.grid-wrapper::-webkit-scrollbar-thumb {
background-color: #adb5bd;
border-radius: 4px;
}
.grid-wrapper::-webkit-scrollbar-thumb:hover {
background-color: #868e96;
}
/*
TABLE BASE STYLE
*/
.pms-grid table {
width: max-content; /* allows scrolling horizontally */
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: #fff;
}
.pms-col-header {
position: relative;
overflow: visible !important;
z-index: 20;
}
.pms-grid th {
padding: 2px 4px !important;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pms-grid td {
padding: 8px 12px !important;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/*
HEADER (STICKY)
*/
.pms-grid thead th {
position: sticky;
top: 0;
z-index: 6;
background: #fff;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
box-shadow: inset 0 -1px 0 #dee2e6;
}
/*
PINNED COLUMNS
*/
.pms-grid td.pinned,
.pms-grid th.pinned {
position: sticky;
background: #fff;
z-index: 5;
border-right: 1px solid #dee2e6 !important;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
}
/* Always sticky first column (checkbox) */
/* .pms-grid th:first-child,
.pms-grid td:first-child {
position: sticky;
left: 0;
z-index: 8;
background: #fff;
width: 40px;
min-width: 40px;
text-align: center;
box-shadow: 1px 0 1px rgba(0, 0, 0, 0.08);
} */
/* Always sticky last column (Actions) */
.pms-grid .th-lastChild,
.pms-grid .td-lastChild {
position: sticky;
right: 0;
z-index: 8;
background: #fff;
box-shadow: -1px 0 1px rgba(0, 0, 0, 0.08);
}
/*
RESIZE HANDLE
*/
.resize-handle {
width: 8px;
height: 100%;
cursor: col-resize;
display: inline-block;
opacity: 0;
transition: opacity 0.2s;
color: var(--bs-primary);
}
.pms-col-header:hover .resize-handle {
opacity: 1;
}
.resize-handle::after {
content: '';
display: block;
width: 1px;
height: 16px;
background: #adb5bd;
margin: 0 auto;
}
/*
ROW HIGHLIGHT
*/
.pms-grid tbody tr:hover {
background-color: #f8f9fa;
}
.pms-grid tbody tr.table-active {
background-color: #e9f5ff !important;
}
.pms-grid tbody td {
background: #fff;
}
/*
MISC FIXES
*/
.vs-th {
background: #f8f9fa;
}
.text-center {
text-align: center !important;
}
.z-6 {
z-index: 6 !important;
}
.z-100{
z-index: 100 !important;
}
/*
PINNED HEADER COLUMNS (Fix for misalignment)
*/
/* Left pinned header cells */
.pms-grid th.pinned-left {
position: sticky;
left: 0;
z-index: 12;
background: #fff;
box-shadow: 1px 0 1px rgba(0, 0, 0, 0.08);
}
/* Right pinned header cells */
.pms-grid th.pinned-right {
position: sticky;
right: 0;
z-index: 12;
background: #fff;
box-shadow: -1px 0 1px rgba(0, 0, 0, 0.08);
}
/* Match body pinned cell behavior */
.pms-grid td.pinned-left {
position: sticky;
left: 0;
z-index: 8;
background: #fff;
box-shadow: 1px 0 1px rgba(0, 0, 0, 0.08);
border-right: 1px solid var(--bs-primary);
}
.pms-grid td.pinned-right {
position: sticky;
right: 0;
z-index: 8;
background: #fff;
box-shadow: -2px 0 3px rgba(0, 0, 0, 0.08);
}
.dropend:hover > .dropdown-menu {
display: block;
}
/* Hover color consistency with Sneat variables */
.dropdown-item:focus,
.dropdown-item:hover {
background-color: rgba(var(--bs-primary-rgb), 0.08);
color: var(--bs-primary);
}
/* Responsive fallback: stack submenu below parent on small screens */
@media (max-width: 768px) {
.dropend .dropdown-menu {
position: relative;
left: 0;
top: 0;
margin-left: 0;
}
}
.dropdown-submenu {
position: relative;
}
.dropdown-submenu > .dropdown-menu {
top: 0;
left: 100%;
margin-left: 0.1rem;
display: none;
}
.dropdown-submenu > .dropdown-menu.show {
display: block;
}

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,218 @@
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 = 5,
columns = [],
}) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [groupBy, setGroupBy] = useState(null);
const [serverGroupRows, setServerGroupRows] = useState(null);
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([]);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(search);
setPage(1); // Important — when search changes, go back to page 1
}, 500);
return () => clearTimeout(handler);
}, [search]);
// 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 () => {
const sortFilters = sortBy.key
? [
{
column: sortBy.key,
sortDescending: sortBy.dir === "desc",
},
]
: [];
const filterPayload = JSON.stringify({
sortFilters,
groupByColumn: groupBy || null,
});
if (!serverMode || typeof fetcher !== "function") return;
setLoading(true);
try {
const resp = await fetcher({
page,
pageSize,
sortBy,
search: debouncedSearch,
filter: filterPayload,
});
// expected: { rows: [], total }
setServerRows(resp.rows || []);
setTotalRows(resp.total || (resp.rows ? resp.rows.length : 0));
} finally {
setLoading(false);
}
}, [serverMode, fetcher, page, pageSize, sortBy, debouncedSearch, groupBy]);
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) => {
// first click = asc
if (prev.key !== key) {
return { key, dir: "asc" };
}
// second click = desc
if (prev.dir === "asc") {
return { key, dir: "desc" };
}
// third click = remove sort
return { key: null, 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,
groupBy,
setGroupBy,
// 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);
}