Change the filter design and refactore the compoents for Image Gallery.

This commit is contained in:
Kartik Sharma 2025-09-02 15:45:12 +05:30
parent 9dddba4e30
commit c23de9bf7c
11 changed files with 629 additions and 593 deletions

View File

@ -0,0 +1,171 @@
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";
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 } = 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]);
// Submit Apply filters
const onSubmit = useCallback(
(formData) => {
const payload = {
...formData,
startDate: formData.startDate
? moment(formData.startDate).utc().toISOString()
: null,
endDate: formData.endDate
? moment(formData.endDate).utc().toISOString()
: null,
};
onApplyFilters(payload);
},
[onApplyFilters]
);
// Clear all filters
const onClear = useCallback(() => {
reset(defaultGalleryFilterValues);
setResetKey((prev) => prev + 1); // reset DateRangePicker
onApplyFilters(defaultGalleryFilterValues);
}, [onApplyFilters, reset]);
return (
<div className="p-3">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="d-flex flex-column">
{/* Date Range */}
<div className="mb-3 text-start">
<label className="">Date Range :</label>
<DateRangePicker1
placeholder="DD-MM-YYYY To DD-MM-YYYY"
startField="startDate"
endField="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-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;

View File

@ -0,0 +1,169 @@
import React, { useRef, useState, useCallback } from "react";
import Avatar from "../../components/common/Avatar";
import ImagePopup from "./ImagePopup";
const ImageGalleryListView = ({
images,
isLoading,
isFetchingNextPage,
hasNextPage,
loaderRef,
openModal,
SCROLL_THRESHOLD,
formatUTCToLocalTime,
moment,
}) => {
const [hoveredImage, setHoveredImage] = useState(null);
const imageGroupRefs = useRef({});
const scrollLeft = useCallback(
(key) =>
imageGroupRefs.current[key]?.scrollBy({ left: -200, behavior: "smooth" }),
[]
);
const scrollRight = useCallback(
(key) =>
imageGroupRefs.current[key]?.scrollBy({ left: 200, behavior: "smooth" }),
[]
);
return (
<div className="main-content">
<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>
{/* Images */}
<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)}
>
<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>
);
};
export default ImageGalleryListView;

View File

@ -0,0 +1,90 @@
import React, { useState, useEffect } from "react";
import "./ImagePopup.css"
import { useModal } from "./ModalContext";
import moment from "moment";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
const ImagePopup = ({ 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 class='bx bxs-calendar' ></i> <span className="text-muted">Date : </span> <span className="text-secondary"> {date}</span></div>
<div className="flex alig-items-center"> <i class='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 ImagePopup;

View File

@ -8,7 +8,7 @@ import { Provider } from 'react-redux';
import { store } from './store/store'; import { store } from './store/store';
import { ModalProvider } from './ModalContext.jsx'; import { ModalProvider } from './ModalContext.jsx';
import { ChangePasswordProvider } from './components/Context/ChangePasswordContext.jsx'; 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( createRoot(document.getElementById('root')!).render(

View File

@ -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;

View File

@ -1,91 +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 class='bx bxs-calendar' ></i> <span className="text-muted">Date : </span> <span className="text-secondary"> {date}</span></div>
<div className="flex alig-items-center"> <i class='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;

View File

@ -0,0 +1,196 @@
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 { 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";
// --- NEW IMPORTS ---
import { useFab } from "../../Context/FabContext";
const SCROLL_THRESHOLD = 5;
const ImageGalleryPage = () => {
const selectedProjectId = useSelector((store) => store.localVariables.projectId);
const dispatch = useDispatch();
const { projectNames } = useProjectName();
const loaderRef = useRef(null);
const { openModal } = useModal();
// Auto-select first project if none selected
useEffect(() => {
if (!selectedProjectId && projectNames?.length) {
dispatch(setProjectId(projectNames[0].id));
}
}, [selectedProjectId, projectNames, dispatch]);
// --- Filters ---
const [appliedFilters, setAppliedFilters] = useState({
buildingIds: null,
floorIds: null,
activityIds: null,
uploadedByIds: null,
workCategoryIds: null,
workAreaIds: null,
startDate: null,
endDate: null,
});
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
refetch,
} = useImageGallery(selectedProjectId, appliedFilters);
const images = data?.pages.flatMap((page) => page.data) || [];
// --- 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();
// --- Apply filters callback ---
const handleApplyFilters = useCallback((values) => {
setAppliedFilters(values);
}, []);
// --- Filter Panel Memoization ---
const filterPanelElement = useMemo(
() => (
<ImageGalleryFilters
buildings={buildings}
floors={floors}
activities={activities}
workAreas={workAreas}
workCategories={workCategories}
uploadedByUsers={uploadedByUsers}
appliedFilters={appliedFilters}
onApplyFilters={handleApplyFilters}
/>
),
[
buildings,
floors,
activities,
workAreas,
workCategories,
uploadedByUsers,
appliedFilters,
handleApplyFilters,
]
);
// --- Fab Offcanvas Integration ---
const { setOffcanvasContent, setShowTrigger } = useFab();
useEffect(() => {
setShowTrigger(true);
setOffcanvasContent("Gallery Filters", filterPanelElement);
return () => {
setShowTrigger(false);
setOffcanvasContent("", null);
};
}, [filterPanelElement, setOffcanvasContent, setShowTrigger]);
// --- Refetch on project or filters ---
useEffect(() => {
if (selectedProjectId) refetch();
}, [selectedProjectId, appliedFilters, refetch]);
// --- EventBus Refetch ---
useEffect(() => {
const handler = (data) => {
if (data.projectId === selectedProjectId) refetch();
};
eventBus.on("image_gallery", handler);
return () => eventBus.off("image_gallery", handler);
}, [selectedProjectId, refetch]);
// --- Infinite scroll observer ---
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]);
return (
<div className="gallery-container container-fluid">
<Breadcrumb data={[{ label: "Home", link: "/" }, { label: "Gallery" }]} />
<ImageGalleryListView
images={images}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
loaderRef={loaderRef}
openModal={openModal}
SCROLL_THRESHOLD={SCROLL_THRESHOLD}
formatUTCToLocalTime={formatUTCToLocalTime}
moment={moment}
/>
</div>
);
};
export default ImageGalleryPage;

View File

@ -26,7 +26,6 @@ import AttendancePage from "../pages/Activities/AttendancePage";
import DailyTask from "../pages/Activities/DailyTask"; import DailyTask from "../pages/Activities/DailyTask";
import TaskPlannng from "../pages/Activities/TaskPlannng"; import TaskPlannng from "../pages/Activities/TaskPlannng";
import Reports from "../pages/reports/Reports"; import Reports from "../pages/reports/Reports";
import ImageGallary from "../pages/Gallary/ImageGallary";
import MasterPage from "../pages/master/MasterPage"; import MasterPage from "../pages/master/MasterPage";
import Support from "../pages/support/Support"; import Support from "../pages/support/Support";
import Documentation from "../pages/support/Documentation"; import Documentation from "../pages/support/Documentation";
@ -44,6 +43,7 @@ import ExpensePage from "../pages/Expense/ExpensePage";
import TenantDetails from "../pages/Tenant/TenantDetails"; import TenantDetails from "../pages/Tenant/TenantDetails";
import SelfTenantDetails from "../pages/Tenant/SelfTenantDetails"; import SelfTenantDetails from "../pages/Tenant/SelfTenantDetails";
import SuperTenantDetails from "../pages/Tenant/SuperTenantDetails"; import SuperTenantDetails from "../pages/Tenant/SuperTenantDetails";
import ImageGalleryPage from "../pages/ImageGallery/ImageGalleryPage";
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
@ -81,7 +81,7 @@ const router = createBrowserRouter(
{ path: "/activities/records/:projectId?", element: <DailyTask /> }, { path: "/activities/records/:projectId?", element: <DailyTask /> },
{ path: "/activities/task", element: <TaskPlannng /> }, { path: "/activities/task", element: <TaskPlannng /> },
{ path: "/activities/reports", element: <Reports /> }, { path: "/activities/reports", element: <Reports /> },
{ path: "/gallary", element: <ImageGallary /> }, { path: "/gallary", element: <ImageGalleryPage /> },
{ path: "/expenses", element: <ExpensePage /> }, { path: "/expenses", element: <ExpensePage /> },
{ path: "/masters", element: <MasterPage /> }, { path: "/masters", element: <MasterPage /> },
{ path: "/tenants", element: <TenantPage /> }, { path: "/tenants", element: <TenantPage /> },