Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 102a88aee6 | |||
| 29f297740b | |||
| 6a7066c771 | |||
| 0f8c70a897 | |||
| 27cbd18fa1 | |||
| 9dc2f811dd | |||
| e1f72828f9 | |||
| ee1cf01743 | |||
| b8861fbf41 | |||
| 6d72ed4735 | |||
| c615678981 | |||
| 817cfb2731 | |||
| d96a1a88ec | |||
| 7b0659e820 | |||
| 96faf07311 | |||
| e2bd654c9c | |||
| d839f631f8 | |||
| ab5ae6491d | |||
| 09b8f8d3d2 | |||
| e77fbf6d1d | |||
| 73f437e911 | |||
| 7329319417 | |||
| c594d146cc | |||
| cbe0b188c4 |
20
public/assets/vendor/css/core.css
vendored
20
public/assets/vendor/css/core.css
vendored
@ -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;
|
||||||
|
|||||||
4
src/assets/vendor/css/core.css
vendored
4
src/assets/vendor/css/core.css
vendored
@ -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;
|
||||||
|
|||||||
@ -85,8 +85,9 @@ 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 ?? ""} ${
|
||||||
}`.trim();
|
item.createdBy?.lastName ?? ""
|
||||||
|
}`.trim();
|
||||||
displayField = "Submitted By";
|
displayField = "Submitted By";
|
||||||
break;
|
break;
|
||||||
case "project":
|
case "project":
|
||||||
|
|||||||
35
src/components/Project/pmsInfrastructure/BuildingTable.jsx
Normal file
35
src/components/Project/pmsInfrastructure/BuildingTable.jsx
Normal 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} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/Project/pmsInfrastructure/FloorTable.jsx
Normal file
28
src/components/Project/pmsInfrastructure/FloorTable.jsx
Normal 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} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/Project/pmsInfrastructure/WorkAreaTable.jsx
Normal file
39
src/components/Project/pmsInfrastructure/WorkAreaTable.jsx
Normal 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]} />;
|
||||||
|
// }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,149 +159,152 @@ 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>
|
||||||
|
<div className="card p-2">
|
||||||
|
<PmGridCollection selectedProject={selectedProject} fromDate={localToUtc(fromDate)} toDate={localToUtc(toDate)} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
121
src/components/collections/PmGridCollection.jsx
Normal file
121
src/components/collections/PmGridCollection.jsx
Normal 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;
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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 /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
811
src/services/pmsGrid/BasicTable.jsx
Normal file
811
src/services/pmsGrid/BasicTable.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/services/pmsGrid/GridPinnedCalculator.js
Normal file
40
src/services/pmsGrid/GridPinnedCalculator.js
Normal 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;
|
||||||
|
}
|
||||||
65
src/services/pmsGrid/GridService.js
Normal file
65
src/services/pmsGrid/GridService.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
|
||||||
|
export const sortArray = (data = [], columnKey, direction = 'asc') => {
|
||||||
|
if (!columnKey) return data;
|
||||||
|
const dir = direction === 'asc' ? 1 : -1;
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const A = a[columnKey];
|
||||||
|
const B = b[columnKey];
|
||||||
|
if (A === B) return 0;
|
||||||
|
if (A == null) return -1 * dir;
|
||||||
|
if (B == null) return 1 * dir;
|
||||||
|
if (typeof A === 'number' && typeof B === 'number') return (A - B) * dir;
|
||||||
|
return String(A).localeCompare(String(B)) * dir;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterArray = (data = [], searchTerm = '', columns = []) => {
|
||||||
|
if (!searchTerm) return data;
|
||||||
|
const q = String(searchTerm).toLowerCase();
|
||||||
|
return data.filter(row =>
|
||||||
|
columns.some(col => {
|
||||||
|
const val = row[col];
|
||||||
|
if (val === undefined || val === null) return false;
|
||||||
|
return String(val).toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const paginate = (data = [], page = 1, pageSize = 10) => {
|
||||||
|
const total = data.length;
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize;
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
rows: data.slice(from, to),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// selection helpers
|
||||||
|
export const toggleItem = (selected = new Set(), id) => {
|
||||||
|
const s = new Set(selected);
|
||||||
|
if (s.has(id)) s.delete(id);
|
||||||
|
else s.add(id);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectAll = (rows = [], idKey = 'id') =>
|
||||||
|
new Set(rows.map(r => r[idKey]));
|
||||||
|
|
||||||
|
export const clearSelection = () => new Set();
|
||||||
|
// src/services/pmsGrid/usePmsPopup.js
|
||||||
|
export function initPopover(selector = '[data-bs-toggle="popover"]') {
|
||||||
|
if (!window.bootstrap || !window.bootstrap.Popover) {
|
||||||
|
console.warn('⚠️ Bootstrap JS not found — make sure you included bootstrap.bundle.min.js in index.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
if (!elements.length) return;
|
||||||
|
elements.forEach(el => new window.bootstrap.Popover(el));
|
||||||
|
});
|
||||||
|
}
|
||||||
653
src/services/pmsGrid/PmsGrid.jsx
Normal file
653
src/services/pmsGrid/PmsGrid.jsx
Normal 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
|
||||||
197
src/services/pmsGrid/PmsHeaderOption.jsx
Normal file
197
src/services/pmsGrid/PmsHeaderOption.jsx
Normal 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;
|
||||||
5
src/services/pmsGrid/index.js
Normal file
5
src/services/pmsGrid/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export { default as PmsGrid } from "./PmsGrid";
|
||||||
|
export * from "./useGrid";
|
||||||
|
export * from "./useGridCore";
|
||||||
|
export * from "./utils";
|
||||||
257
src/services/pmsGrid/pms-grid.css
Normal file
257
src/services/pmsGrid/pms-grid.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
46
src/services/pmsGrid/useGrid.js
Normal file
46
src/services/pmsGrid/useGrid.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { sortArray, filterArray, paginate, toggleItem, selectAll, clearSelection } from "./GridService";
|
||||||
|
|
||||||
|
export const useGrid = ({ data = [], columns = [], idKey = "id", initialPageSize = 10 }) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState({ key: null, dir: "asc" });
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [expanded, setExpanded] = useState(new Set());
|
||||||
|
|
||||||
|
const filtered = useMemo(() => filterArray(data, search, columns.map(c => c.key)), [data, search, columns]);
|
||||||
|
const sorted = useMemo(() => sortArray(filtered, sortBy.key, sortBy.dir), [filtered, sortBy]);
|
||||||
|
const pageResult = useMemo(() => paginate(sorted, page, pageSize), [sorted, page, pageSize]);
|
||||||
|
|
||||||
|
const toggleSelect = useCallback(id => setSelected(prev => toggleItem(prev, id)), []);
|
||||||
|
const selectAllOnPage = useCallback(rows => setSelected(selectAll(rows, idKey)), [idKey]);
|
||||||
|
const deselectAllOnPage = useCallback(rows => setSelected(prev => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
rows.forEach(r => s.delete(r[idKey]));
|
||||||
|
return s;
|
||||||
|
}), [idKey]);
|
||||||
|
const clearAllSelection = useCallback(() => setSelected(clearSelection()), []);
|
||||||
|
const changeSort = useCallback(key => {
|
||||||
|
setSortBy(prev => (prev.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" }));
|
||||||
|
}, []);
|
||||||
|
const toggleExpand = useCallback(id => {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
search, setSearch,
|
||||||
|
sortBy, changeSort,
|
||||||
|
page, setPage,
|
||||||
|
pageSize, setPageSize,
|
||||||
|
selected, toggleSelect, selectAllOnPage, deselectAllOnPage, clearAllSelection,
|
||||||
|
expanded, toggleExpand,
|
||||||
|
pageResult,
|
||||||
|
totalRows: sorted.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
218
src/services/pmsGrid/useGridCore.js
Normal file
218
src/services/pmsGrid/useGridCore.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/services/pmsGrid/utils.js
Normal file
19
src/services/pmsGrid/utils.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
export function exportToCSV(rows = [], columns = [], filename = "export.csv") {
|
||||||
|
const cols = columns.filter(c => c.visible !== false);
|
||||||
|
const header = cols.map(c => `"${(c.title || c.key).replace(/"/g, '""')}"`).join(",");
|
||||||
|
const lines = rows.map(r => cols.map(c => {
|
||||||
|
const v = c.exportValue ? c.exportValue(r) : (r[c.key] ?? "");
|
||||||
|
return `"${String(v).replace(/"/g,'""')}"`;
|
||||||
|
}).join(","));
|
||||||
|
const csv = [header, ...lines].join("\r\n");
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", filename);
|
||||||
|
link.style.visibility = "hidden";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user