Compare commits
17 Commits
main
...
Image_Gall
Author | SHA1 | Date | |
---|---|---|---|
5d002413db | |||
c2bac4e640 | |||
1e25e2d1ad | |||
59b62d2f10 | |||
3df68dff89 | |||
98acafb9ff | |||
32bcaf2602 | |||
eaccf0fca2 | |||
d4be35b44d | |||
40fb010128 | |||
24a412a289 | |||
9d5c33eeff | |||
850190daf6 | |||
4476286b63 | |||
d67a185146 | |||
6f515e0700 | |||
c23de9bf7c |
46
src/components/Charts/ImageGallerySkeleton.jsx
Normal file
46
src/components/Charts/ImageGallerySkeleton.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
const ImageCardSkeleton = ({ count = 1 }) => {
|
||||
const cards = Array.from({ length: count });
|
||||
|
||||
return (
|
||||
<div className="row g-3">
|
||||
{cards.map((_, idx) => (
|
||||
<div key={idx} className="col-12">
|
||||
<div className="card p-3">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<div
|
||||
className="rounded-circle bg-light placeholder"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<div className="ms-2 flex-grow-1">
|
||||
<h6 className="placeholder-glow mb-1">
|
||||
<span className="placeholder col-6 bg-light"></span>
|
||||
</h6>
|
||||
<small className="placeholder-glow">
|
||||
<span className="placeholder col-4 bg-light"></span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-light placeholder mb-2"
|
||||
style={{ width: "100%", height: "150px", borderRadius: "4px" }}
|
||||
/>
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div className="placeholder-glow">
|
||||
<span className="placeholder col-4 bg-light"></span>
|
||||
</div>
|
||||
<div className="placeholder-glow">
|
||||
<span className="placeholder col-2 bg-light"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageCardSkeleton;
|
174
src/components/ImageGallery/ImageGalleryFilters.jsx
Normal file
174
src/components/ImageGallery/ImageGalleryFilters.jsx
Normal file
@ -0,0 +1,174 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import moment from "moment";
|
||||
import DateRangePicker, { DateRangePicker1 } from "../../components/common/DateRangePicker";
|
||||
import SelectMultiple from "../../components/common/SelectMultiple";
|
||||
import { localToUtc } from "../../utils/appUtils";
|
||||
|
||||
const defaultGalleryFilterValues = {
|
||||
buildingIds: [],
|
||||
floorIds: [],
|
||||
activityIds: [],
|
||||
uploadedByIds: [],
|
||||
workCategoryIds: [],
|
||||
workAreaIds: [],
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
};
|
||||
|
||||
const ImageGalleryFilters = ({
|
||||
buildings = [],
|
||||
floors = [],
|
||||
activities = [],
|
||||
workAreas = [],
|
||||
workCategories = [],
|
||||
uploadedByUsers = [],
|
||||
onApplyFilters,
|
||||
appliedFilters,
|
||||
}) => {
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: defaultGalleryFilterValues,
|
||||
});
|
||||
|
||||
const { handleSubmit, reset, setValue } = methods;
|
||||
|
||||
// Prefill form when appliedFilters changes
|
||||
useEffect(() => {
|
||||
if (appliedFilters) {
|
||||
reset({
|
||||
buildingIds: appliedFilters.buildingIds || [],
|
||||
floorIds: appliedFilters.floorIds || [],
|
||||
activityIds: appliedFilters.activityIds || [],
|
||||
uploadedByIds: appliedFilters.uploadedByIds || [],
|
||||
workCategoryIds: appliedFilters.workCategoryIds || [],
|
||||
workAreaIds: appliedFilters.workAreaIds || [],
|
||||
startDate: appliedFilters.startDate || null,
|
||||
endDate: appliedFilters.endDate || null,
|
||||
});
|
||||
}
|
||||
}, [appliedFilters, reset]);
|
||||
|
||||
// Handle date range change and set form values
|
||||
const handleDateRangeChange = useCallback(
|
||||
({ startDate, endDate }) => {
|
||||
setValue("startDate", startDate);
|
||||
setValue("endDate", endDate);
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const onSubmit =(formData)=>{
|
||||
const inputStartDate = localToUtc(formData.startDate)
|
||||
const inputEndDate = localToUtc(formData.endDate)
|
||||
const payload = {
|
||||
...formData,
|
||||
startDate: inputStartDate,
|
||||
endDate: inputEndDate,
|
||||
};
|
||||
onApplyFilters(payload);
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
const onClear = useCallback(() => {
|
||||
reset(defaultGalleryFilterValues);
|
||||
setResetKey((prev) => prev + 1);
|
||||
onApplyFilters(defaultGalleryFilterValues);
|
||||
}, [onApplyFilters, reset]);
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="d-flex flex-column">
|
||||
<div className="mb-3 text-start">
|
||||
<label className="">Date Range :</label>
|
||||
<DateRangePicker1
|
||||
onRangeChange={handleDateRangeChange}
|
||||
startDate="startDate"
|
||||
endDate="endDate"
|
||||
resetSignal={resetKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multi-select dropdowns */}
|
||||
<div className="mb-3 text-start">
|
||||
<SelectMultiple
|
||||
name="buildingIds"
|
||||
label="Buildings :"
|
||||
options={buildings.map(([id, name]) => ({ id, name }))}
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-start">
|
||||
<SelectMultiple
|
||||
name="floorIds"
|
||||
label="Floors :"
|
||||
options={floors.map(([id, name]) => ({ id, name }))}
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-start">
|
||||
<SelectMultiple
|
||||
name="workAreaIds"
|
||||
label="Work Areas :"
|
||||
options={workAreas.map(([id, name]) => ({ id, name }))}
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-start">
|
||||
<SelectMultiple
|
||||
name="activityIds"
|
||||
label="Activities :"
|
||||
options={activities.map(([id, name]) => ({ id, name }))}
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-start">
|
||||
<SelectMultiple
|
||||
name="uploadedByIds"
|
||||
label="Uploaded By (User) :"
|
||||
options={uploadedByUsers.map(([id, name]) => ({ id, name }))}
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-start">
|
||||
<SelectMultiple
|
||||
name="workCategoryIds"
|
||||
label="Work Categories :"
|
||||
options={workCategories.map(([id, name]) => ({ id, name }))}
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="d-flex justify-content-end py-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-label-secondary btn-xs"
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary btn-xs">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGalleryFilters;
|
152
src/components/ImageGallery/ImageGalleryListView.jsx
Normal file
152
src/components/ImageGallery/ImageGalleryListView.jsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React, { useRef, useState, useCallback, useEffect } from "react";
|
||||
import Avatar from "../../components/common/Avatar";
|
||||
import ImagePopup from "./ImagePopup";
|
||||
|
||||
const ImageGalleryListView = ({
|
||||
images,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
loaderRef,
|
||||
openModal,
|
||||
formatUTCToLocalTime,
|
||||
moment,
|
||||
}) => {
|
||||
const [hoveredImage, setHoveredImage] = useState(null);
|
||||
const [scrollThreshold, setScrollThreshold] = useState(5);
|
||||
const imageGroupRefs = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
const updateThreshold = () => {
|
||||
if (window.innerWidth >= 1400) setScrollThreshold(6);
|
||||
else if (window.innerWidth >= 992) setScrollThreshold(5);
|
||||
else if (window.innerWidth >= 768) setScrollThreshold(4);
|
||||
else setScrollThreshold(3);
|
||||
};
|
||||
updateThreshold();
|
||||
window.addEventListener("resize", updateThreshold);
|
||||
return () => window.removeEventListener("resize", updateThreshold);
|
||||
}, []);
|
||||
|
||||
const scrollLeft = useCallback(
|
||||
(key) =>
|
||||
imageGroupRefs.current[key]?.scrollBy({ left: -200, behavior: "smooth" }),
|
||||
[]
|
||||
);
|
||||
const scrollRight = useCallback(
|
||||
(key) =>
|
||||
imageGroupRefs.current[key]?.scrollBy({ left: 200, behavior: "smooth" }),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!images.length && !isLoading) {
|
||||
return <p className="text-center text-muted mt-5">No images match the selected filters.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="activity-section">
|
||||
{images.map((batch) => {
|
||||
if (!batch.documents?.length) return null; // skip empty batches
|
||||
|
||||
const doc = batch.documents[0];
|
||||
const userName = `${doc.uploadedBy?.firstName || ""} ${doc.uploadedBy?.lastName || ""}`.trim();
|
||||
const date = formatUTCToLocalTime(doc.uploadedAt);
|
||||
const hasArrows = batch.documents.length > scrollThreshold;
|
||||
|
||||
return (
|
||||
<div key={batch.batchId} className="grouped-section">
|
||||
<div className="group-heading">
|
||||
<div className="d-flex align-items-center mb-1">
|
||||
<Avatar
|
||||
size="xs"
|
||||
firstName={doc.uploadedBy?.firstName}
|
||||
lastName={doc.uploadedBy?.lastName}
|
||||
className="me-2"
|
||||
/>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
<strong className="user-name-text">{userName}</strong>
|
||||
<span className="text-muted small">{date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="location-line text-secondary">
|
||||
<div className="d-flex align-items-center flex-wrap gap-1 text-secondary">
|
||||
<span className="d-flex align-items-center">
|
||||
<span>{batch.buildingName}</span>
|
||||
<i className="bx bx-chevron-right " />
|
||||
</span>
|
||||
<span className="d-flex align-items-center">
|
||||
<span>{batch.floorName}</span>
|
||||
<i className="bx bx-chevron-right m" />
|
||||
</span>
|
||||
<span className="d-flex align-items-center ">
|
||||
<span>{batch.workAreaName || "Unknown"}</span>
|
||||
<i className="bx bx-chevron-right " />
|
||||
<span>{batch.activityName}</span>
|
||||
</span>
|
||||
</div>
|
||||
{batch.workCategoryName && (
|
||||
<span className="badge bg-label-primary ms-2">{batch.workCategoryName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image-group-wrapper">
|
||||
{hasArrows && (
|
||||
<button className="scroll-arrow left-arrow" onClick={() => scrollLeft(batch.batchId)}>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="image-group-horizontal"
|
||||
ref={(el) => (imageGroupRefs.current[batch.batchId] = el)}
|
||||
>
|
||||
{batch.documents.map((d, i) => {
|
||||
const hoverDate = moment(d.uploadedAt).format("DD MMMM, YYYY");
|
||||
const hoverTime = moment(d.uploadedAt).format("hh:mm A");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className="image-card"
|
||||
onClick={() => openModal(<ImagePopup batch={batch} initialIndex={i} />)}
|
||||
onMouseEnter={() => setHoveredImage(d)}
|
||||
onMouseLeave={() => setHoveredImage(null)}
|
||||
>
|
||||
<div className="image-wrapper">
|
||||
<img src={d.url} alt={`Image ${i + 1}`} />
|
||||
</div>
|
||||
{hoveredImage === d && (
|
||||
<div className="image-hover-description">
|
||||
<p><strong>Date:</strong> {hoverDate}</p>
|
||||
<p><strong>Time:</strong> {hoverTime}</p>
|
||||
<p><strong>Activity:</strong> {batch.activityName}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasArrows && (
|
||||
<button className="scroll-arrow right-arrow" onClick={() => scrollRight(batch.batchId)}>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div ref={loaderRef}>
|
||||
{isFetchingNextPage && hasNextPage && <p>Loading...</p>}
|
||||
{!hasNextPage && !isLoading && images.length > 0 && (
|
||||
<p className="text-muted">You've reached the end of the images.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGalleryListView;
|
128
src/components/ImageGallery/ImagePopup.jsx
Normal file
128
src/components/ImageGallery/ImagePopup.jsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useModal } from "./ModalContext";
|
||||
import { formatUTCToLocalTime } from "../../utils/dateUtils";
|
||||
|
||||
const ImagePopup = ({ batch, initialIndex = 0 }) => {
|
||||
const { closeModal } = useModal();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(initialIndex);
|
||||
}, [initialIndex, batch]);
|
||||
|
||||
if (!batch || !batch.documents || batch.documents.length === 0) return null;
|
||||
|
||||
const image = batch.documents[currentIndex];
|
||||
if (!image) return null;
|
||||
|
||||
const fullName = `${image.uploadedBy?.firstName || ""} ${image.uploadedBy?.lastName || ""
|
||||
}`.trim();
|
||||
const date = formatUTCToLocalTime(image.uploadedAt);
|
||||
|
||||
const buildingName = batch.buildingName;
|
||||
const floorName = batch.floorName;
|
||||
const workAreaName = batch.workAreaName;
|
||||
const activityName = batch.activityName;
|
||||
const batchComment = batch.comment;
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentIndex((prevIndex) => Math.max(0, prevIndex - 1));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
Math.min(batch.documents.length - 1, prevIndex + 1)
|
||||
);
|
||||
};
|
||||
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < batch.documents.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal fade show"
|
||||
style={{ display: "block", backgroundColor: "rgba(0,0,0,0.6)", overflow: "hidden", }}
|
||||
tabIndex="-1"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="modal-dialog modal-md modal-dialog-centered" role="document">
|
||||
<div className="modal-content border-0 shadow-lg rounded-3">
|
||||
{/* Header */}
|
||||
<div className="modal-header justify-content-center py-2">
|
||||
<h5 className="modal-title">Image Preview</h5>
|
||||
<button type="button" className="btn-close" onClick={closeModal}></button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="modal-body p-2 text-center">
|
||||
<div className="position-relative d-flex justify-content-center align-items-center">
|
||||
{hasPrev && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary rounded-circle d-flex align-items-center justify-content-center position-absolute start-0 top-50 translate-middle-y shadow"
|
||||
style={{ marginLeft: "-50px", width: "40px", height: "40px" }}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<i className="bx bx-chevron-left fs-4"></i>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={image.url}
|
||||
alt="Preview"
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
maxHeight: "500px", // bigger image
|
||||
width: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasNext && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary rounded-circle d-flex align-items-center justify-content-center position-absolute end-0 top-50 translate-middle-y shadow"
|
||||
style={{ marginRight: "-50px", width: "40px", height: "40px" }}
|
||||
onClick={handleNext}
|
||||
>
|
||||
<i className="bx bx-chevron-right fs-4"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="mt-3 text-start small">
|
||||
<p className="mb-1">
|
||||
<i className="bx bxs-user me-2"></i>
|
||||
<span className="text-muted">Uploaded By: </span>
|
||||
<span className="fw-semibold">{fullName}</span>
|
||||
</p>
|
||||
|
||||
<p className="mb-1">
|
||||
<i className="bx bxs-calendar me-2"></i>
|
||||
<span className="text-muted">Date: </span>
|
||||
<span className="fw-semibold">{date}</span>
|
||||
</p>
|
||||
|
||||
<p className="mb-1">
|
||||
<i className="bx bx-map me-2"></i>
|
||||
<span className="text-muted">Location: </span>
|
||||
<span className="fw-semibold">
|
||||
{buildingName} <i className="bx bx-chevron-right"></i> {floorName}{" "}
|
||||
<i className="bx bx-chevron-right"></i> {workAreaName || "Unknown"}{" "}
|
||||
<i className="bx bx-chevron-right"></i> {activityName}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="mb-0">
|
||||
<i className="bx bx-comment-dots me-2"></i>
|
||||
<span className="text-muted">Comment: </span>
|
||||
<span className="fw-semibold">{batchComment}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePopup;
|
@ -67,6 +67,8 @@ const DateRangePicker = ({
|
||||
style={{ right: "22px", bottom: "-8px" }}
|
||||
></i>
|
||||
</div>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -89,16 +89,19 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const useImageGallery = (selectedProjectId, filters) => {
|
||||
const hasFilters = filters && Object.values(filters).some(
|
||||
value => Array.isArray(value) ? value.length > 0 : value !== null && value !== ""
|
||||
);
|
||||
const hasFilters =
|
||||
filters &&
|
||||
Object.values(filters).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : value !== null && value !== ""
|
||||
);
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["imageGallery", selectedProjectId, hasFilters ? filters : null],
|
||||
enabled: !!selectedProjectId,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (!lastPage?.data?.length) return undefined;
|
||||
return allPages.length + 1;
|
||||
getNextPageParam: (lastPage) => {
|
||||
const currentPage = lastPage?.data?.currentPage || 1;
|
||||
const totalPages = lastPage?.data?.totalPages || 1;
|
||||
return currentPage < totalPages ? currentPage + 1 : undefined;
|
||||
},
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const res = await ImageGalleryAPI.ImagesGet(
|
||||
@ -107,7 +110,7 @@ const useImageGallery = (selectedProjectId, filters) => {
|
||||
pageParam,
|
||||
PAGE_SIZE
|
||||
);
|
||||
return res;
|
||||
return res.data; // Important: use res.data to match API response
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -6,7 +6,7 @@ import App from './App.tsx'
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store/store';
|
||||
import { ChangePasswordProvider } from './components/Context/ChangePasswordContext.jsx';
|
||||
import { ModalProvider1 } from './pages/Gallary/ModalContext.jsx';
|
||||
import { ModalProvider1 } from './components/ImageGallery/ModalContext.jsx';
|
||||
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
@ -1,499 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import "./ImageGallery.css";
|
||||
import moment from "moment";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useModal } from "./ModalContext";
|
||||
import ImagePop from "./ImagePop";
|
||||
import Avatar from "../../components/common/Avatar";
|
||||
import DateRangePicker from "../../components/common/DateRangePicker";
|
||||
import eventBus from "../../services/eventBus";
|
||||
import Breadcrumb from "../../components/common/Breadcrumb";
|
||||
import { formatUTCToLocalTime } from "../../utils/dateUtils";
|
||||
import useImageGallery from "../../hooks/useImageGallery";
|
||||
import { useProjectName } from "../../hooks/useProjects";
|
||||
import { setProjectId } from "../../slices/localVariablesSlice";
|
||||
|
||||
const SCROLL_THRESHOLD = 5;
|
||||
|
||||
const ImageGallery = () => {
|
||||
const selectedProjectId = useSelector(
|
||||
(store) => store.localVariables.projectId
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const { projectNames } = useProjectName();
|
||||
|
||||
// Auto-select a project on mount
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId && projectNames?.length) {
|
||||
dispatch(setProjectId(projectNames[0].id));
|
||||
}
|
||||
}, [selectedProjectId, projectNames, dispatch]);
|
||||
|
||||
// Filter states
|
||||
const [selectedFilters, setSelectedFilters] = useState({
|
||||
building: [],
|
||||
floor: [],
|
||||
activity: [],
|
||||
uploadedBy: [],
|
||||
workCategory: [],
|
||||
workArea: [],
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
buildingIds: null,
|
||||
floorIds: null,
|
||||
activityIds: null,
|
||||
uploadedByIds: null,
|
||||
workCategoryIds: null,
|
||||
workAreaIds: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
const [collapsedFilters, setCollapsedFilters] = useState({
|
||||
dateRange: false,
|
||||
building: false,
|
||||
floor: false,
|
||||
activity: false,
|
||||
uploadedBy: false,
|
||||
workCategory: false,
|
||||
workArea: false,
|
||||
});
|
||||
const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
|
||||
const [hoveredImage, setHoveredImage] = useState(null);
|
||||
|
||||
const imageGroupRefs = useRef({});
|
||||
const loaderRef = useRef(null);
|
||||
const filterPanelRef = useRef(null);
|
||||
const filterButtonRef = useRef(null);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useImageGallery(selectedProjectId, appliedFilters);
|
||||
|
||||
const images = data?.pages.flatMap((page) => page.data) || [];
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (
|
||||
filterPanelRef.current &&
|
||||
!filterPanelRef.current.contains(e.target) &&
|
||||
filterButtonRef.current &&
|
||||
!filterButtonRef.current.contains(e.target)
|
||||
) {
|
||||
setIsFilterPanelOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) refetch();
|
||||
}, [selectedProjectId, appliedFilters, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (data) => {
|
||||
if (data.projectId === selectedProjectId) refetch();
|
||||
};
|
||||
eventBus.on("image_gallery", handler);
|
||||
return () => eventBus.off("image_gallery", handler);
|
||||
}, [selectedProjectId, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaderRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px", threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(loaderRef.current);
|
||||
|
||||
return () => {
|
||||
if (loaderRef.current) {
|
||||
observer.unobserve(loaderRef.current);
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
|
||||
// Utility: derive filter options
|
||||
const getUniqueValues = useCallback(
|
||||
(idKey, nameKey) => {
|
||||
const m = new Map();
|
||||
images.forEach((batch) => {
|
||||
const id = idKey === "floorIds" ? batch.floorIds : batch[idKey];
|
||||
if (id && batch[nameKey] && !m.has(id)) {
|
||||
m.set(id, batch[nameKey]);
|
||||
}
|
||||
});
|
||||
return [...m.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
},
|
||||
[images]
|
||||
);
|
||||
|
||||
const getUploadedBy = useCallback(() => {
|
||||
const m = new Map();
|
||||
images.forEach((batch) => {
|
||||
batch.documents.forEach((doc) => {
|
||||
const name = `${doc.uploadedBy?.firstName || ""} ${
|
||||
doc.uploadedBy?.lastName || ""
|
||||
}`.trim();
|
||||
if (doc.uploadedBy?.id && name && !m.has(doc.uploadedBy.id)) {
|
||||
m.set(doc.uploadedBy.id, name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return [...m.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
}, [images]);
|
||||
|
||||
const buildings = getUniqueValues("buildingId", "buildingName");
|
||||
const floors = getUniqueValues("floorIds", "floorName");
|
||||
const activities = getUniqueValues("activityId", "activityName");
|
||||
const workAreas = getUniqueValues("workAreaId", "workAreaName");
|
||||
const workCategories = getUniqueValues("workCategoryId", "workCategoryName");
|
||||
const uploadedByUsers = getUploadedBy();
|
||||
|
||||
const toggleFilter = useCallback((type, id, name) => {
|
||||
setSelectedFilters((prev) => {
|
||||
const arr = prev[type];
|
||||
const exists = arr.some(([x]) => x === id);
|
||||
const updated = exists
|
||||
? arr.filter(([x]) => x !== id)
|
||||
: [...arr, [id, name]];
|
||||
return { ...prev, [type]: updated };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDateRange = useCallback(({ startDate, endDate }) => {
|
||||
setSelectedFilters((prev) => ({
|
||||
...prev,
|
||||
startDate: startDate || "",
|
||||
endDate: endDate || "",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleCollapse = useCallback((type) => {
|
||||
setCollapsedFilters((prev) => ({ ...prev, [type]: !prev[type] }));
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = useCallback(() => {
|
||||
const payload = {
|
||||
buildingIds: selectedFilters.building.map(([x]) => x) || null,
|
||||
floorIds: selectedFilters.floor.map(([x]) => x) || null,
|
||||
activityIds: selectedFilters.activity.map(([x]) => x) || null,
|
||||
uploadedByIds: selectedFilters.uploadedBy.map(([x]) => x) || null,
|
||||
workCategoryIds: selectedFilters.workCategory.map(([x]) => x) || null,
|
||||
workAreaIds: selectedFilters.workArea.map(([x]) => x) || null,
|
||||
startDate: selectedFilters.startDate || null,
|
||||
endDate: selectedFilters.endDate || null,
|
||||
};
|
||||
const changed = Object.keys(payload).some((key) => {
|
||||
const oldVal = appliedFilters[key],
|
||||
newVal = payload[key];
|
||||
return Array.isArray(oldVal)
|
||||
? oldVal.length !== newVal.length ||
|
||||
oldVal.some((x) => !newVal.includes(x))
|
||||
: oldVal !== newVal;
|
||||
});
|
||||
if (changed) setAppliedFilters(payload);
|
||||
}, [selectedFilters, appliedFilters]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setSelectedFilters({
|
||||
building: [],
|
||||
floor: [],
|
||||
activity: [],
|
||||
uploadedBy: [],
|
||||
workCategory: [],
|
||||
workArea: [],
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
setAppliedFilters({
|
||||
buildingIds: null,
|
||||
floorIds: null,
|
||||
activityIds: null,
|
||||
uploadedByIds: null,
|
||||
workCategoryIds: null,
|
||||
workAreaIds: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollLeft = useCallback(
|
||||
(key) =>
|
||||
imageGroupRefs.current[key]?.scrollBy({ left: -200, behavior: "smooth" }),
|
||||
[]
|
||||
);
|
||||
const scrollRight = useCallback(
|
||||
(key) =>
|
||||
imageGroupRefs.current[key]?.scrollBy({ left: 200, behavior: "smooth" }),
|
||||
[]
|
||||
);
|
||||
|
||||
const renderCategory = (label, items, type) => (
|
||||
<div
|
||||
className={`dropdown my-2 ${collapsedFilters[type] ? "collapsed" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="dropdown-header bg-label-primary"
|
||||
onClick={() => toggleCollapse(type)}
|
||||
>
|
||||
<strong>{label}</strong>
|
||||
<div className="header-controls">
|
||||
{((type === "dateRange" &&
|
||||
(selectedFilters.startDate || selectedFilters.endDate)) ||
|
||||
(type !== "dateRange" && selectedFilters[type]?.length > 0)) && (
|
||||
<button
|
||||
className="clear-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFilters((prev) => ({
|
||||
...prev,
|
||||
[type]: type === "dateRange" ? "" : [],
|
||||
...(type === "dateRange" && { startDate: "", endDate: "" }),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<span className="collapse-icon">
|
||||
{collapsedFilters[type] ? "+" : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!collapsedFilters[type] && (
|
||||
<div className="dropdown-content">
|
||||
{type === "dateRange" ? (
|
||||
<DateRangePicker
|
||||
onRangeChange={setDateRange}
|
||||
endDateMode="today"
|
||||
startDate={selectedFilters.startDate}
|
||||
endDate={selectedFilters.endDate}
|
||||
/>
|
||||
) : (
|
||||
items.map(([id, name]) => (
|
||||
<label key={id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFilters[type].some(([x]) => x === id)}
|
||||
onChange={() => toggleFilter(type, id, name)}
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gallery-container container-fluid ${
|
||||
isFilterPanelOpen ? "filter-panel-open-end" : ""
|
||||
}`}
|
||||
>
|
||||
<Breadcrumb data={[{ label: "Home", link: "/" }, { label: "Gallery" }]} />
|
||||
<div className="main-content">
|
||||
<button
|
||||
className={`filter-button btn-primary ${
|
||||
isFilterPanelOpen ? "closed-icon" : ""
|
||||
}`}
|
||||
onClick={() => setIsFilterPanelOpen((p) => !p)}
|
||||
ref={filterButtonRef}
|
||||
>
|
||||
{isFilterPanelOpen ? (
|
||||
<i className="fa-solid fa-times fs-5" />
|
||||
) : (
|
||||
<i className="bx bx-slider-alt ms-1" />
|
||||
)}
|
||||
</button>
|
||||
<div className="activity-section">
|
||||
{isLoading ? (
|
||||
<div className="text-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : images.length ? (
|
||||
images.map((batch) => {
|
||||
const doc = batch.documents[0];
|
||||
const userName = `${doc.uploadedBy?.firstName || ""} ${
|
||||
doc.uploadedBy?.lastName || ""
|
||||
}`.trim();
|
||||
const date = formatUTCToLocalTime(doc.uploadedAt);
|
||||
const hasArrows = batch.documents.length > SCROLL_THRESHOLD;
|
||||
return (
|
||||
<div key={batch.batchId} className="grouped-section">
|
||||
<div className="group-heading">
|
||||
{/* Uploader Info */}
|
||||
<div className="d-flex align-items-center mb-1">
|
||||
<Avatar
|
||||
size="xs"
|
||||
firstName={doc.uploadedBy?.firstName}
|
||||
lastName={doc.uploadedBy?.lastName}
|
||||
className="me-2"
|
||||
/>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
<strong className="user-name-text">{userName}</strong>
|
||||
<span className="text-muted small">{date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location Info */}
|
||||
<div className="location-line text-secondary">
|
||||
<div className="d-flex align-items-center flex-wrap gap-1 text-secondary">
|
||||
{" "}
|
||||
<span className="d-flex align-items-center">
|
||||
<span>{batch.buildingName}</span>
|
||||
<i className="bx bx-chevron-right " />
|
||||
</span>
|
||||
<span className="d-flex align-items-center">
|
||||
<span>{batch.floorName}</span>
|
||||
<i className="bx bx-chevron-right m" />
|
||||
</span>
|
||||
<span className="d-flex align-items-center ">
|
||||
<span>{batch.workAreaName || "Unknown"}</span>
|
||||
<i className="bx bx-chevron-right " />
|
||||
<span>{batch.activityName}</span>
|
||||
</span>
|
||||
</div>
|
||||
{batch.workCategoryName && (
|
||||
<span className="badge bg-label-primary ms-2">
|
||||
{batch.workCategoryName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image-group-wrapper">
|
||||
{hasArrows && (
|
||||
<button
|
||||
className="scroll-arrow left-arrow"
|
||||
onClick={() => scrollLeft(batch.batchId)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="image-group-horizontal"
|
||||
ref={(el) => (imageGroupRefs.current[batch.batchId] = el)}
|
||||
>
|
||||
{batch.documents.map((d, i) => {
|
||||
const hoverDate = moment(d.uploadedAt).format(
|
||||
"DD MMMM, YYYY"
|
||||
);
|
||||
const hoverTime = moment(d.uploadedAt).format(
|
||||
"hh:mm A"
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className="image-card"
|
||||
onClick={() =>
|
||||
openModal(
|
||||
<ImagePop batch={batch} initialIndex={i} />
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => setHoveredImage(d)}
|
||||
onMouseLeave={() => setHoveredImage(null)}
|
||||
>
|
||||
<div className="image-wrapper">
|
||||
<img src={d.url} alt={`Image ${i + 1}`} />
|
||||
</div>
|
||||
{hoveredImage === d && (
|
||||
<div className="image-hover-description">
|
||||
<p>
|
||||
<strong>Date:</strong> {hoverDate}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Time:</strong> {hoverTime}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Activity:</strong>{" "}
|
||||
{batch.activityName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasArrows && (
|
||||
<button
|
||||
className="scroll-arrow right-arrow"
|
||||
onClick={() => scrollRight(batch.batchId)}
|
||||
>
|
||||
<i className="bx bx-chevron-right"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-center text-muted mt-5">
|
||||
No images match the selected filters.
|
||||
</p>
|
||||
)}
|
||||
<div ref={loaderRef}>
|
||||
{isFetchingNextPage && hasNextPage && <p>Loading...</p>}
|
||||
{!hasNextPage && !isLoading && images.length > 0 && (
|
||||
<p className="text-muted">
|
||||
You've reached the end of the images.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`offcanvas offcanvas-end ${isFilterPanelOpen ? "show" : ""}`}
|
||||
ref={filterPanelRef}
|
||||
>
|
||||
<div className="offcanvas-header">
|
||||
<h5>Filters</h5>
|
||||
<button
|
||||
className="btn-close"
|
||||
onClick={() => setIsFilterPanelOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-actions mt-auto mx-2">
|
||||
<button className="btn btn-secondary btn-xs" onClick={handleClear}>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-xs"
|
||||
onClick={handleApplyFilters}
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
<div className="offcanvas-body d-flex flex-column">
|
||||
{renderCategory("Date Range", [], "dateRange")}
|
||||
{renderCategory("Building", buildings, "building")}
|
||||
{renderCategory("Floor", floors, "floor")}
|
||||
{renderCategory("Work Area", workAreas, "workArea")}
|
||||
{renderCategory("Activity", activities, "activity")}
|
||||
{renderCategory("Uploaded By (User)", uploadedByUsers, "uploadedBy")}
|
||||
{renderCategory("Work Category", workCategories, "workCategory")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGallery;
|
@ -1,111 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./ImagePop.css";
|
||||
import { useModal } from "./ModalContext";
|
||||
import moment from "moment";
|
||||
import { formatUTCToLocalTime } from "../../utils/dateUtils";
|
||||
|
||||
const ImagePop = ({ batch, initialIndex = 0 }) => {
|
||||
const { closeModal } = useModal();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
|
||||
// Effect to update currentIndex if the initialIndex prop changes
|
||||
useEffect(() => {
|
||||
setCurrentIndex(initialIndex);
|
||||
}, [initialIndex, batch]);
|
||||
|
||||
// If no batch or documents are provided, don't render
|
||||
if (!batch || !batch.documents || batch.documents.length === 0) return null;
|
||||
|
||||
// Get the current image document from the batch's documents array
|
||||
const image = batch.documents[currentIndex];
|
||||
|
||||
// Fallback if for some reason the image at the current index doesn't exist
|
||||
if (!image) return null;
|
||||
|
||||
// Format details for display from the individual image document
|
||||
const fullName = `${image.uploadedBy?.firstName || ""} ${
|
||||
image.uploadedBy?.lastName || ""
|
||||
}`.trim();
|
||||
const date = formatUTCToLocalTime(image.uploadedAt);
|
||||
|
||||
// Location and category details from the 'batch' object (as previously corrected)
|
||||
const buildingName = batch.buildingName;
|
||||
const floorName = batch.floorName;
|
||||
const workAreaName = batch.workAreaName;
|
||||
const activityName = batch.activityName;
|
||||
const batchComment = batch.comment;
|
||||
|
||||
// Handler for navigating to the previous image
|
||||
const handlePrev = () => {
|
||||
setCurrentIndex((prevIndex) => Math.max(0, prevIndex - 1));
|
||||
};
|
||||
|
||||
// Handler for navigating to the next image
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
Math.min(batch.documents.length - 1, prevIndex + 1)
|
||||
);
|
||||
};
|
||||
|
||||
// Determine if previous/next buttons should be enabled/visible
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < batch.documents.length - 1;
|
||||
|
||||
return (
|
||||
<div className="image-modal-overlay">
|
||||
<div className="image-modal-content">
|
||||
<i className="bx bx-x close-button" onClick={closeModal}></i>
|
||||
|
||||
{hasPrev && (
|
||||
<button className="nav-button prev-button" onClick={handlePrev}>
|
||||
<i className="bx bx-chevron-left"></i>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="image-container">
|
||||
<img src={image.url} alt="Preview" className="modal-image" />
|
||||
</div>
|
||||
|
||||
{hasNext && (
|
||||
<button className="nav-button next-button" onClick={handleNext}>
|
||||
<i className="bx bx-chevron-right"></i>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="image-details">
|
||||
<div className="flex alig-items-center">
|
||||
{" "}
|
||||
<i className="bx bxs-user"></i>{" "}
|
||||
<span className="text-muted">Uploaded By : </span>{" "}
|
||||
<span className="text-secondary">{fullName}</span>
|
||||
</div>
|
||||
<div className="flex alig-items-center">
|
||||
{" "}
|
||||
<i className="bx bxs-calendar"></i>{" "}
|
||||
<span className="text-muted">Date : </span>{" "}
|
||||
<span className="text-secondary"> {date}</span>
|
||||
</div>
|
||||
<div className="flex alig-items-center">
|
||||
{" "}
|
||||
<i className="bx bx-map"></i>{" "}
|
||||
<span className="text-muted">Uploaded By : </span>{" "}
|
||||
<span className="text-secondary">
|
||||
{buildingName} <i className="bx bx-chevron-right"></i> {floorName}{" "}
|
||||
<i className="bx bx-chevron-right"></i>
|
||||
{workAreaName || "Unknown"}{" "}
|
||||
<i className="bx bx-chevron-right"></i> {activityName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex alig-items-center">
|
||||
{" "}
|
||||
<i className="bx bx-comment-dots"></i>{" "}
|
||||
<span className="text-muted">comment : </span>{" "}
|
||||
<span className="text-secondary">{batchComment}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePop;
|
294
src/pages/ImageGallery/ImageGalleryPage.jsx
Normal file
294
src/pages/ImageGallery/ImageGalleryPage.jsx
Normal file
@ -0,0 +1,294 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import moment from "moment";
|
||||
import { useModal } from "../../components/ImageGallery/ModalContext";
|
||||
import eventBus from "../../services/eventBus";
|
||||
import Breadcrumb from "../../components/common/Breadcrumb";
|
||||
import { formatUTCToLocalTime } from "../../utils/dateUtils";
|
||||
import useImageGallery from "../../hooks/useImageGallery";
|
||||
import { useProjectAssignedServices, useProjectName } from "../../hooks/useProjects";
|
||||
import { setProjectId } from "../../slices/localVariablesSlice";
|
||||
import ImageGalleryListView from "../../components/ImageGallery/ImageGalleryListView";
|
||||
import ImageGalleryFilters from "../../components/ImageGallery/ImageGalleryFilters";
|
||||
import "../../components/ImageGallery/ImageGallery.css";
|
||||
import { useFab } from "../../Context/FabContext";
|
||||
import ImageGallerySkeleton from "../../components/Charts/ImageGallerySkeleton";
|
||||
|
||||
const ImageGalleryPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
const selectedProjectId = useSelector((store) => store.localVariables.projectId);
|
||||
const { projectNames } = useProjectName();
|
||||
const loaderRef = useRef(null);
|
||||
const { openModal } = useModal();
|
||||
const { setOffcanvasContent, setShowTrigger } = useFab();
|
||||
|
||||
const { data: assignedServices = [], isLoading: servicesLoading } =
|
||||
useProjectAssignedServices(selectedProjectId);
|
||||
|
||||
const [selectedService, setSelectedService] = useState("");
|
||||
|
||||
const handleServiceChange = (e) => {
|
||||
setSelectedService(e.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId && projectNames?.length) {
|
||||
dispatch(setProjectId(projectNames[0].id));
|
||||
}
|
||||
}, [selectedProjectId, projectNames, dispatch]);
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
buildingIds: [],
|
||||
floorIds: [],
|
||||
activityIds: [],
|
||||
uploadedByIds: [],
|
||||
workCategoryIds: [],
|
||||
workAreaIds: [],
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage, refetch } =
|
||||
useImageGallery(selectedProjectId, appliedFilters);
|
||||
|
||||
const images = data?.pages.flatMap((page) => page.data) || [];
|
||||
|
||||
const [labelMaps, setLabelMaps] = useState({
|
||||
buildings: new Map(),
|
||||
floors: new Map(),
|
||||
activities: new Map(),
|
||||
workAreas: new Map(),
|
||||
workCategories: new Map(),
|
||||
uploadedByUsers: new Map(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const buildingsMap = new Map(labelMaps.buildings);
|
||||
const floorsMap = new Map(labelMaps.floors);
|
||||
const activitiesMap = new Map(labelMaps.activities);
|
||||
const workAreasMap = new Map(labelMaps.workAreas);
|
||||
const workCategoriesMap = new Map(labelMaps.workCategories);
|
||||
const uploadedByMap = new Map(labelMaps.uploadedByUsers);
|
||||
|
||||
images.forEach((batch) => {
|
||||
if (batch.buildingId && batch.buildingName) buildingsMap.set(batch.buildingId, batch.buildingName);
|
||||
if (batch.floorIds && batch.floorName) floorsMap.set(batch.floorIds, batch.floorName);
|
||||
if (batch.activityId && batch.activityName) activitiesMap.set(batch.activityId, batch.activityName);
|
||||
if (batch.workAreaId && batch.workAreaName) workAreasMap.set(batch.workAreaId, batch.workAreaName);
|
||||
if (batch.workCategoryId && batch.workCategoryName) workCategoriesMap.set(batch.workCategoryId, batch.workCategoryName);
|
||||
batch.documents?.forEach((doc) => {
|
||||
const name = `${doc.uploadedBy?.firstName || ""} ${doc.uploadedBy?.lastName || ""}`.trim();
|
||||
if (doc.uploadedBy?.id && name) uploadedByMap.set(doc.uploadedBy.id, name);
|
||||
});
|
||||
});
|
||||
|
||||
setLabelMaps({
|
||||
buildings: buildingsMap,
|
||||
floors: floorsMap,
|
||||
activities: activitiesMap,
|
||||
workAreas: workAreasMap,
|
||||
workCategories: workCategoriesMap,
|
||||
uploadedByUsers: uploadedByMap,
|
||||
});
|
||||
}, [images]);
|
||||
|
||||
const handleApplyFilters = useCallback((values) => setAppliedFilters(values), []);
|
||||
|
||||
const handleRemoveFilter = (filterKey, valueId) => {
|
||||
setAppliedFilters((prev) => {
|
||||
const updated = { ...prev };
|
||||
if (Array.isArray(updated[filterKey])) {
|
||||
updated[filterKey] = updated[filterKey].filter((id) => id !== valueId);
|
||||
} else if (filterKey === "startDate" || filterKey === "endDate" || filterKey === "dateRange") {
|
||||
updated.startDate = null;
|
||||
updated.endDate = null;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const appliedFiltersChips = useMemo(() => {
|
||||
const chips = [];
|
||||
const { buildings, floors, activities, workAreas, workCategories, uploadedByUsers } = labelMaps;
|
||||
|
||||
appliedFilters.buildingIds?.forEach((id) =>
|
||||
chips.push({ label: "Building", value: buildings.get(id) || id, key: "buildingIds", id })
|
||||
);
|
||||
appliedFilters.floorIds?.forEach((id) =>
|
||||
chips.push({ label: "Floor", value: floors.get(id) || id, key: "floorIds", id })
|
||||
);
|
||||
appliedFilters.workAreaIds?.forEach((id) =>
|
||||
chips.push({ label: "Work Area", value: workAreas.get(id) || id, key: "workAreaIds", id })
|
||||
);
|
||||
appliedFilters.activityIds?.forEach((id) =>
|
||||
chips.push({ label: "Activity", value: activities.get(id) || id, key: "activityIds", id })
|
||||
);
|
||||
appliedFilters.uploadedByIds?.forEach((id) =>
|
||||
chips.push({ label: "Uploaded By", value: uploadedByUsers.get(id) || id, key: "uploadedByIds", id })
|
||||
);
|
||||
appliedFilters.workCategoryIds?.forEach((id) =>
|
||||
chips.push({ label: "Work Category", value: workCategories.get(id) || id, key: "workCategoryIds", id })
|
||||
);
|
||||
|
||||
if (appliedFilters.startDate || appliedFilters.endDate) {
|
||||
const start = appliedFilters.startDate ? moment(appliedFilters.startDate).format("DD MMM, YYYY") : "";
|
||||
const end = appliedFilters.endDate ? moment(appliedFilters.endDate).format("DD MMM, YYYY") : "";
|
||||
chips.push({ label: "Date Range", value: `${start} - ${end}`, key: "dateRange" });
|
||||
}
|
||||
return chips;
|
||||
}, [appliedFilters, labelMaps]);
|
||||
|
||||
useEffect(() => { refetch(); }, [appliedFilters, refetch]);
|
||||
|
||||
const filterPanelElement = useMemo(
|
||||
() => (
|
||||
<ImageGalleryFilters
|
||||
buildings={[...labelMaps.buildings]}
|
||||
floors={[...labelMaps.floors]}
|
||||
activities={[...labelMaps.activities]}
|
||||
workAreas={[...labelMaps.workAreas]}
|
||||
workCategories={[...labelMaps.workCategories]}
|
||||
uploadedByUsers={[...labelMaps.uploadedByUsers]}
|
||||
appliedFilters={appliedFilters}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
removeBg
|
||||
/>
|
||||
),
|
||||
[labelMaps, appliedFilters, handleApplyFilters]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setShowTrigger(true);
|
||||
setOffcanvasContent("Gallery Filters", filterPanelElement);
|
||||
return () => {
|
||||
setShowTrigger(false);
|
||||
setOffcanvasContent("", null);
|
||||
};
|
||||
}, [filterPanelElement, setOffcanvasContent, setShowTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (data) => { if (data.projectId === selectedProjectId) refetch(); };
|
||||
eventBus.on("image_gallery", handler);
|
||||
return () => eventBus.off("image_gallery", handler);
|
||||
}, [selectedProjectId, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaderRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) fetchNextPage();
|
||||
},
|
||||
{ rootMargin: "200px", threshold: 0.1 }
|
||||
);
|
||||
observer.observe(loaderRef.current);
|
||||
return () => loaderRef.current && observer.unobserve(loaderRef.current);
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
return (
|
||||
|
||||
<div className="container-fluid">
|
||||
<Breadcrumb data={[{ label: "Home", link: "/" }, { label: "Gallery" }]} />
|
||||
|
||||
{/* Card wrapper */}
|
||||
<div className="card shadow-sm">
|
||||
<div
|
||||
className="card-body"
|
||||
style={{
|
||||
minHeight: (!images?.length && !isLoading) ? "500px" : "auto",
|
||||
}}
|
||||
>
|
||||
<div className="dataTables_length text-start py-1 px-0 col-md-4 col-12 mb-3">
|
||||
{!servicesLoading && assignedServices?.length > 0 && (
|
||||
assignedServices.length > 1 ? (
|
||||
<label>
|
||||
<select
|
||||
name="DataTables_Table_0_length"
|
||||
aria-controls="DataTables_Table_0"
|
||||
className="form-select form-select-sm"
|
||||
aria-label="Select Service"
|
||||
value={selectedService}
|
||||
onChange={handleServiceChange}
|
||||
style={{ fontSize: "0.875rem", height: "35px", width: "200px" }}
|
||||
>
|
||||
<option value="">All Services</option>
|
||||
{assignedServices.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<h5>{assignedServices[0].name}</h5>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Chips */}
|
||||
{appliedFiltersChips.length > 0 && (
|
||||
<div className="mb-3 d-flex flex-wrap gap-2 align-items-center">
|
||||
<strong className="me-2">Filters:</strong>
|
||||
{["Building", "Floor", "Work Area", "Activity", "Uploaded By", "Work Category"].map((label) => {
|
||||
const chips = appliedFiltersChips.filter(c => c.label === label);
|
||||
if (!chips.length) return null;
|
||||
return (
|
||||
<div key={label} className="d-flex align-items-center gap-1">
|
||||
<strong>{label}:</strong>
|
||||
{chips.map(chip => (
|
||||
<span
|
||||
key={chip.id}
|
||||
className="d-flex align-items-center bg-label-secondary px-2 py-1 rounded"
|
||||
>
|
||||
{chip.value}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white btn-sm ms-1"
|
||||
onClick={() => handleRemoveFilter(chip.key, chip.id)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Date Range */}
|
||||
{appliedFiltersChips.some(c => c.label === "Date Range") && (
|
||||
<div className="d-flex align-items-center bg-label-secondary px-2 py-1 rounded">
|
||||
<strong>Date Range:</strong>
|
||||
{appliedFiltersChips.filter(c => c.label === "Date Range").map((chip, idx) => (
|
||||
<span key={idx} className="d-flex align-items-center ms-1">
|
||||
{chip.value}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white btn-sm ms-1"
|
||||
onClick={() => handleRemoveFilter(chip.key, chip.id)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{isLoading ? (
|
||||
<ImageGallerySkeleton count={4} />
|
||||
) : (
|
||||
<ImageGalleryListView
|
||||
images={images}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loaderRef={loaderRef}
|
||||
openModal={openModal}
|
||||
formatUTCToLocalTime={formatUTCToLocalTime}
|
||||
moment={moment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGalleryPage;
|
@ -22,7 +22,6 @@ import Inventory from "../pages/project/Inventory";
|
||||
import AttendancePage from "../pages/Activities/AttendancePage";
|
||||
import TaskPlannng from "../pages/Activities/TaskPlannng";
|
||||
import Reports from "../pages/reports/Reports";
|
||||
import ImageGallary from "../pages/Gallary/ImageGallary";
|
||||
import MasterPage from "../pages/master/MasterPage";
|
||||
import Support from "../pages/support/Support";
|
||||
import Documentation from "../pages/support/Documentation";
|
||||
@ -37,6 +36,8 @@ import ExpensePage from "../pages/Expense/ExpensePage";
|
||||
import TenantDetails from "../pages/Tenant/TenantDetails";
|
||||
import SelfTenantDetails from "../pages/Tenant/SelfTenantDetails";
|
||||
import SuperTenantDetails from "../pages/Tenant/SuperTenantDetails";
|
||||
import ImageGalleryPage from "../pages/ImageGallery/ImageGalleryPage";
|
||||
|
||||
import DirectoryPage from "../pages/Directory/DirectoryPage";
|
||||
import RootRedirect from "./RootRedirect";
|
||||
import MainLogin from "../pages/authentication/MainLogin";
|
||||
@ -92,7 +93,7 @@ const router = createBrowserRouter(
|
||||
{ path: "/activities/records/:projectId?", element: <DailyProgrssReport /> },
|
||||
{ path: "/activities/task", element: <TaskPlannng /> },
|
||||
{ path: "/activities/reports", element: <Reports /> },
|
||||
{ path: "/gallary", element: <ImageGallary /> },
|
||||
{ path: "/gallary", element: <ImageGalleryPage /> },
|
||||
{ path: "/expenses", element: <ExpensePage /> },
|
||||
{ path: "/masters", element: <MasterPage /> },
|
||||
{ path: "/tenants", element: <TenantPage /> },
|
||||
|
Loading…
x
Reference in New Issue
Block a user