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;
|
||||
}
|
||||
.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 {
|
||||
padding-top: 0.782rem;
|
||||
@ -481,6 +481,24 @@ th {
|
||||
border-style: solid;
|
||||
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 {
|
||||
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-index: 5 !important;
|
||||
}
|
||||
.z-6 {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer !important;
|
||||
|
||||
@ -85,7 +85,8 @@ const ExpenseList = ({ filters, groupBy = "transactionDate", searchText }) => {
|
||||
displayField = "Status";
|
||||
break;
|
||||
case "submittedBy":
|
||||
key = `${item?.createdBy?.firstName ?? ""} ${item.createdBy?.lastName ?? ""
|
||||
key = `${item?.createdBy?.firstName ?? ""} ${
|
||||
item.createdBy?.lastName ?? ""
|
||||
}`.trim();
|
||||
displayField = "Submitted By";
|
||||
break;
|
||||
|
||||
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 { useSelectedProject } from "../../slices/apiDataManager";
|
||||
import { useHasUserPermission } from "../../hooks/useHasUserPermission";
|
||||
import { PmsGrid } from "../../services/pmsGrid";
|
||||
import PmGridCollection from "./PmGridCollection";
|
||||
|
||||
const CollectionList = ({ fromDate, toDate, isPending, searchString }) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@ -28,16 +30,16 @@ const CollectionList = ({ fromDate, toDate, isPending, searchString }) => {
|
||||
const selectedProject = useSelectedProject();
|
||||
const searchDebounce = useDebounce(searchString, 500);
|
||||
|
||||
const { data, isLoading, isError, error } = useCollections(
|
||||
selectedProject,
|
||||
searchDebounce,
|
||||
localToUtc(fromDate),
|
||||
localToUtc(toDate),
|
||||
ITEMS_PER_PAGE,
|
||||
currentPage,
|
||||
true,
|
||||
isPending
|
||||
);
|
||||
// const { data, isLoading, isError, error } = useCollections(
|
||||
// selectedProject,
|
||||
// searchDebounce,
|
||||
// localToUtc(fromDate),
|
||||
// localToUtc(toDate),
|
||||
// ITEMS_PER_PAGE,
|
||||
// currentPage,
|
||||
// true,
|
||||
// isPending
|
||||
// );
|
||||
const { setProcessedPayment, setAddPayment, setViewCollection } =
|
||||
useCollectionContext();
|
||||
const paginate = (page) => {
|
||||
@ -157,148 +159,151 @@ const CollectionList = ({ fromDate, toDate, isPending, searchString }) => {
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <CollectionTableSkeleton />;
|
||||
if (isError) return <p>{error.message}</p>;
|
||||
// if (isLoading) return <CollectionTableSkeleton />;
|
||||
// if (isError) return <p>{error.message}</p>;
|
||||
|
||||
return (
|
||||
<div className="card px-sm-4 px-0">
|
||||
<div
|
||||
className="card-datatable table-responsive page-min-h"
|
||||
id="horizontal-example"
|
||||
>
|
||||
<div className="dataTables_wrapper no-footer mx-3 pb-2">
|
||||
<table className="table dataTable text-nowrap">
|
||||
<thead>
|
||||
<tr className="table_header_border">
|
||||
{collectionColumns.map((col) => (
|
||||
<th key={col.key} className={col.align}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{(isAdmin ||
|
||||
canAddPayment ||
|
||||
canViewCollection ||
|
||||
canEditCollection ||
|
||||
canCreate) && <th>Action</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.isArray(data?.data) && data.data.length > 0 ? (
|
||||
data.data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{collectionColumns.map((col) => (
|
||||
<td key={col.key} className={col.align}>
|
||||
{col.getValue(row)}
|
||||
</td>
|
||||
))}
|
||||
{(isAdmin ||
|
||||
canAddPayment ||
|
||||
canViewCollection ||
|
||||
canEditCollection ||
|
||||
canCreate) && (
|
||||
<td
|
||||
className="sticky-action-column text-center"
|
||||
style={{ padding: "12px 8px" }}
|
||||
>
|
||||
<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>
|
||||
// <div className="card px-sm-4 px-0">
|
||||
// <div
|
||||
// className="card-datatable table-responsive page-min-h"
|
||||
// id="horizontal-example"
|
||||
// >
|
||||
// <div className="dataTables_wrapper no-footer mx-3 pb-2">
|
||||
// <table className="table dataTable text-nowrap">
|
||||
// <thead>
|
||||
// <tr className="table_header_border">
|
||||
// {collectionColumns.map((col) => (
|
||||
// <th key={col.key} className={col.align}>
|
||||
// {col.label}
|
||||
// </th>
|
||||
// ))}
|
||||
// {(isAdmin ||
|
||||
// canAddPayment ||
|
||||
// canViewCollection ||
|
||||
// canEditCollection ||
|
||||
// canCreate) && <th>Action</th>}
|
||||
// </tr>
|
||||
// </thead>
|
||||
// <tbody>
|
||||
// {Array.isArray(data?.data) && data.data.length > 0 ? (
|
||||
// data.data.map((row, i) => (
|
||||
// <tr key={i}>
|
||||
// {collectionColumns.map((col) => (
|
||||
// <td key={col.key} className={col.align}>
|
||||
// {col.getValue(row)}
|
||||
// </td>
|
||||
// ))}
|
||||
// {(isAdmin ||
|
||||
// canAddPayment ||
|
||||
// canViewCollection ||
|
||||
// canEditCollection ||
|
||||
// canCreate) && (
|
||||
// <td
|
||||
// className="sticky-action-column text-center"
|
||||
// style={{ padding: "12px 8px" }}
|
||||
// >
|
||||
// <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">
|
||||
{/* View */}
|
||||
// <ul className="dropdown-menu dropdown-menu-end">
|
||||
// {/* View */}
|
||||
|
||||
<li>
|
||||
<a
|
||||
className="dropdown-item cursor-pointer"
|
||||
onClick={() => setViewCollection(row.id)}
|
||||
>
|
||||
<i className="bx bx-show me-2 text-primary"></i>
|
||||
<span>View</span>
|
||||
</a>
|
||||
</li>
|
||||
// <li>
|
||||
// <a
|
||||
// className="dropdown-item cursor-pointer"
|
||||
// onClick={() => setViewCollection(row.id)}
|
||||
// >
|
||||
// <i className="bx bx-show me-2 text-primary"></i>
|
||||
// <span>View</span>
|
||||
// </a>
|
||||
// </li>
|
||||
|
||||
{/* Only if not completed */}
|
||||
{!row?.markAsCompleted && (
|
||||
<>
|
||||
{/* Add Payment */}
|
||||
{(isAdmin || canAddPayment) && (
|
||||
<li>
|
||||
<a
|
||||
className="dropdown-item cursor-pointer"
|
||||
onClick={() =>
|
||||
setAddPayment({
|
||||
isOpen: true,
|
||||
invoiceId: row.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<i className="bx bx-wallet me-2 text-warning"></i>
|
||||
<span>Add Payment</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
// {/* Only if not completed */}
|
||||
// {!row?.markAsCompleted && (
|
||||
// <>
|
||||
// {/* Add Payment */}
|
||||
// {(isAdmin || canAddPayment) && (
|
||||
// <li>
|
||||
// <a
|
||||
// className="dropdown-item cursor-pointer"
|
||||
// onClick={() =>
|
||||
// setAddPayment({
|
||||
// isOpen: true,
|
||||
// invoiceId: row.id,
|
||||
// })
|
||||
// }
|
||||
// >
|
||||
// <i className="bx bx-wallet me-2 text-warning"></i>
|
||||
// <span>Add Payment</span>
|
||||
// </a>
|
||||
// </li>
|
||||
// )}
|
||||
|
||||
{/* Mark Payment */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<a
|
||||
className="dropdown-item cursor-pointer"
|
||||
onClick={() =>
|
||||
setProcessedPayment({
|
||||
isOpen: true,
|
||||
invoiceId: row.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<i className="bx bx-check-circle me-2 text-success"></i>
|
||||
<span>Mark Payment</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr style={{ height: "200px" }}>
|
||||
<td
|
||||
colSpan={collectionColumns.length + 1}
|
||||
className="text-center border-0 align-middle"
|
||||
>
|
||||
No Collections Found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{data?.data?.length > 0 && (
|
||||
<div className="d-flex justify-content-start mt-2">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={data?.totalPages}
|
||||
onPageChange={paginate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
// {/* Mark Payment */}
|
||||
// {isAdmin && (
|
||||
// <li>
|
||||
// <a
|
||||
// className="dropdown-item cursor-pointer"
|
||||
// onClick={() =>
|
||||
// setProcessedPayment({
|
||||
// isOpen: true,
|
||||
// invoiceId: row.id,
|
||||
// })
|
||||
// }
|
||||
// >
|
||||
// <i className="bx bx-check-circle me-2 text-success"></i>
|
||||
// <span>Mark Payment</span>
|
||||
// </a>
|
||||
// </li>
|
||||
// )}
|
||||
// </>
|
||||
// )}
|
||||
// </ul>
|
||||
// </div>
|
||||
// </td>
|
||||
// )}
|
||||
// </tr>
|
||||
// ))
|
||||
// ) : (
|
||||
// <tr style={{ height: "200px" }}>
|
||||
// <td
|
||||
// colSpan={collectionColumns.length + 1}
|
||||
// className="text-center border-0 align-middle"
|
||||
// >
|
||||
// No Collections Found
|
||||
// </td>
|
||||
// </tr>
|
||||
// )}
|
||||
// </tbody>
|
||||
// </table>
|
||||
// {data?.data?.length > 0 && (
|
||||
// <div className="d-flex justify-content-start mt-2">
|
||||
// <Pagination
|
||||
// currentPage={currentPage}
|
||||
// totalPages={data?.totalPages}
|
||||
// onPageChange={paginate}
|
||||
// />
|
||||
// </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) => (
|
||||
<li
|
||||
key={org}
|
||||
className="ropdown-item"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: "5px 12px",
|
||||
|
||||
@ -24,6 +24,7 @@ import { useProjectAccess } from "../../hooks/useProjectAccess";
|
||||
|
||||
import "./ProjectDetails.css";
|
||||
import ProjectOrganizations from "../../components/Project/ProjectOrganizations";
|
||||
import BuildingTable from "../../components/Project/pmsInfrastructure/BuildingTable";
|
||||
|
||||
const ProjectDetails = () => {
|
||||
const projectId = useSelectedProject();
|
||||
@ -95,7 +96,7 @@ const ProjectDetails = () => {
|
||||
case "teams":
|
||||
return <Teams />;
|
||||
case "infra":
|
||||
return <ProjectInfra data={projects_Details} onDataChange={refetch} />;
|
||||
return <BuildingTable/>
|
||||
case "workplan":
|
||||
return <WorkPlan data={projects_Details} onDataChange={refetch} />;
|
||||
case "directory":
|
||||
|
||||
@ -5,7 +5,7 @@ export const CollectionRepository = {
|
||||
createNewCollection: (data) =>
|
||||
api.post(`/api/Collection/invoice/create`, 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) => {
|
||||
// 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);
|
||||
// },
|
||||
|
||||
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`;
|
||||
const params = [];
|
||||
|
||||
@ -33,6 +43,7 @@ export const CollectionRepository = {
|
||||
if (pageNumber) params.push(`pageNumber=${pageNumber}`);
|
||||
if (isActive) params.push(`isActive=${isActive}`);
|
||||
if (isPending) params.push(`isPending=${isPending}`);
|
||||
if (filter) params.push(`filter=${filter}`);
|
||||
|
||||
if (params.length > 0) {
|
||||
url += "?" + params.join("&");
|
||||
@ -41,9 +52,10 @@ export const CollectionRepository = {
|
||||
return api.get(url);
|
||||
},
|
||||
|
||||
makeReceivePayment: (data) => api.post(`/api/Collection/invoice/payment/received`, data),
|
||||
markPaymentReceived: (invoiceId) => api.put(`/api/Collection/invoice/marked/completed/${invoiceId}`),
|
||||
makeReceivePayment: (data) =>
|
||||
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}`),
|
||||
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 ManageJob from "../components/ServiceProject/ServiceProjectJob/ManageJob";
|
||||
import AdvancePaymentPage1 from "../pages/AdvancePayment/AdvancePaymentPage1";
|
||||
import DemoBOQGrid from "../services/pmsGrid/BasicTable";
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
@ -81,6 +82,8 @@ const router = createBrowserRouter(
|
||||
],
|
||||
},
|
||||
{ path: "/auth/switch/org", element: <TenantSelectionPage /> },
|
||||
{ path: "/help/docs", element: <DemoBOQGrid /> },
|
||||
|
||||
{
|
||||
path: "/auth/subscripe/:frequency/:planId",
|
||||
element: <MakeSubscription />,
|
||||
@ -127,8 +130,7 @@ const router = createBrowserRouter(
|
||||
{ path: "/tenant/self", element: <SelfTenantDetails /> },
|
||||
{ path: "/organizations", element: <OrganizationPage /> },
|
||||
{ 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