diff --git a/frontend/src/Components/RoomBooking.jsx b/frontend/src/Components/RoomBooking.jsx
index 4ac60021..3875309e 100644
--- a/frontend/src/Components/RoomBooking.jsx
+++ b/frontend/src/Components/RoomBooking.jsx
@@ -1,20 +1,28 @@
-import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
CalendarDays,
CheckCircle2,
+ ChevronLeft,
+ ChevronRight,
Clock3,
+ Filter,
MapPin,
Plus,
RefreshCcw,
Search,
+ Users,
X,
XCircle,
+ History,
+ Info
} from "lucide-react";
import { toast } from "react-toastify";
import { AdminContext } from "../context/AdminContext";
import api from "../utils/api";
+// ─── Constants ───────────────────────────────────────────────────────────────
+
const ADMIN_ROLES = [
"PRESIDENT",
"GENSEC_SCITECH",
@@ -36,6 +44,12 @@ const PRESET_AMENITIES = [
"Smart Display",
];
+const HOUR_START = 0;
+const HOUR_END = 24;
+const HOUR_WIDTH_PX = 80;
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
const toDateInput = (date) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
@@ -52,6 +66,16 @@ const getTodayStart = () => {
return now;
};
+const parseLocalDate = (value) => {
+ if (!value) return new Date(NaN);
+ if (value instanceof Date) return value;
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
+ const [year, month, day] = value.split("-").map(Number);
+ return new Date(year, month - 1, day);
+ }
+ return new Date(value);
+};
+
const formatDateTime = (value) =>
new Date(value).toLocaleString("en-IN", {
day: "2-digit",
@@ -61,12 +85,20 @@ const formatDateTime = (value) =>
minute: "2-digit",
});
-const formatDate = (value) =>
- new Date(value).toLocaleDateString("en-IN", {
+const formatDate = (value) => {
+ const d = parseLocalDate(value);
+ return d.toLocaleDateString("en-IN", {
day: "2-digit",
month: "short",
year: "numeric",
});
+};
+
+const formatTime = (value) =>
+ new Date(value).toLocaleTimeString("en-IN", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
const sameDay = (value, dateStr) => {
if (!value || !dateStr) return false;
@@ -80,52 +112,82 @@ const sameDay = (value, dateStr) => {
};
const isClashing = (existingBooking, startTime, endTime) => {
- if (!["Pending", "Approved"].includes(existingBooking.status)) {
- return false;
- }
-
+ if (!["Pending", "Approved"].includes(existingBooking.status)) return false;
const existingStart = new Date(existingBooking.startTime);
const existingEnd = new Date(existingBooking.endTime);
-
return existingStart < endTime && existingEnd > startTime;
};
+// ─── Style helpers ────────────────────────────────────────────────────────────
+
const statusStyleMap = {
- Pending: "bg-amber-100 text-amber-800",
- Approved: "bg-emerald-100 text-emerald-800",
- Rejected: "bg-rose-100 text-rose-800",
- Cancelled: "bg-slate-200 text-slate-700",
+ Pending: "bg-amber-100 text-amber-800 border-amber-200",
+ Approved: "bg-stone-100 text-stone-800 border-stone-200",
+ Rejected: "bg-rose-100 text-rose-800 border-rose-200",
+ Cancelled: "bg-slate-200 text-slate-700 border-slate-300",
+};
+
+const timelineColorMap = {
+ Pending: "bg-amber-100 border-amber-300 text-amber-900 shadow-sm",
+ Approved: "bg-stone-100 border-stone-300 text-stone-900 shadow-sm",
};
+// Premium segmented control style
const tabStyle = (isActive) =>
- `rounded-lg px-3 py-2 text-sm font-medium transition ${
+ `rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
isActive
- ? "bg-slate-900 text-white"
- : "bg-white border border-slate-300 text-slate-700 hover:bg-slate-100"
+ ? "bg-white text-slate-900 shadow-sm ring-1 ring-slate-200/50"
+ : "text-slate-600 hover:text-slate-900 hover:bg-slate-200/50"
}`;
-const RoomBooking = () => {
- const { isUserLoggedIn } = useContext(AdminContext);
- const userRole = isUserLoggedIn?.role || "STUDENT";
- const username = isUserLoggedIn?.username || "";
- const userId = isUserLoggedIn?._id;
+// ─── Modal Component ──────────────────────────────────────────────────────────
- const canBook = ADMIN_ROLES.includes(userRole);
- const canApprove = userRole === APPROVAL_UI_ROLE;
- const showMyRequests = canBook && !canApprove;
+const Modal = ({ open, onClose, title, children }) => {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e) => e.key === "Escape" && onClose();
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
- const [rooms, setRooms] = useState([]);
- const [events, setEvents] = useState([]);
- const [bookings, setBookings] = useState([]);
+ if (!open) return null;
- const [loading, setLoading] = useState(true);
- const [submittingBooking, setSubmittingBooking] = useState(false);
- const [creatingRoom, setCreatingRoom] = useState(false);
+ return (
+
e.target === e.currentTarget && onClose()}
+ >
+
+
+
{title}
+
+
+
{children}
+
+
+
+ );
+};
- const [showRoomForm, setShowRoomForm] = useState(false);
- const [activeTab, setActiveTab] = useState("request");
+// ─── Create Room Modal ────────────────────────────────────────────────────────
- const [roomForm, setRoomForm] = useState({
+const CreateRoomModal = ({ open, onClose, onSubmit, submitting }) => {
+ const [form, setForm] = useState({
name: "",
capacity: "",
location: "",
@@ -133,312 +195,798 @@ const RoomBooking = () => {
customAmenity: "",
});
- const [filters, setFilters] = useState({
- roomId: "",
- date: toDateInput(new Date()),
- status: "",
- });
-
- const [bookingForm, setBookingForm] = useState({
- roomId: "",
- eventId: "",
- date: toDateInput(new Date()),
- startTime: "10:00",
- endTime: "11:00",
- purpose: "",
- });
-
- const fetchRooms = async () => {
- const response = await api.get("/api/rooms/rooms");
- return response.data || [];
- };
+ // Reset form when modal opens
+ useEffect(() => {
+ if (open) {
+ setForm({ name: "", capacity: "", location: "", amenities: [], customAmenity: "" });
+ }
+ }, [open]);
- const fetchBookings = async () => {
- const response = await api.get("/api/rooms/bookings");
- return response.data || [];
+ const addAmenityChip = () => {
+ const value = form.customAmenity.trim();
+ if (!value) return;
+ if (form.amenities.some((a) => a.toLowerCase() === value.toLowerCase())) {
+ toast.info("Amenity already added.");
+ return;
+ }
+ setForm((prev) => ({ ...prev, amenities: [...prev.amenities, value], customAmenity: "" }));
};
- const refreshData = async () => {
- try {
- const [roomsData, bookingsData] = await Promise.all([
- fetchRooms(),
- fetchBookings(),
- ]);
+ const toggleAmenity = (amenity) =>
+ setForm((prev) => ({
+ ...prev,
+ amenities: prev.amenities.includes(amenity)
+ ? prev.amenities.filter((a) => a !== amenity)
+ : [...prev.amenities, amenity],
+ }));
- setRooms(roomsData);
- setBookings(bookingsData);
+ const removeAmenity = (amenity) =>
+ setForm((prev) => ({
+ ...prev,
+ amenities: prev.amenities.filter((a) => a !== amenity),
+ }));
- if (!filters.roomId && roomsData.length > 0) {
- setFilters((prev) => ({ ...prev, roomId: roomsData[0]._id }));
- }
- if (!bookingForm.roomId && roomsData.length > 0) {
- setBookingForm((prev) => ({ ...prev, roomId: roomsData[0]._id }));
- }
- } catch (err) {
- toast.error(
- err.response?.data?.message || "Failed to refresh room bookings.",
- );
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit(form);
};
- useEffect(() => {
- const initialize = async () => {
- try {
- setLoading(true);
-
- let eventsRequest = Promise.resolve([]);
- if (userRole && userRole !== "STUDENT") {
- let eventsUrl = `/api/events/by-role/${userRole}`;
- if (userRole === "CLUB_COORDINATOR" && username) {
- eventsUrl += `?username=${encodeURIComponent(username)}`;
- }
- eventsRequest = api
- .get(eventsUrl)
- .then((response) => response.data || []);
- }
+ return (
+
+
+
+ );
+};
- if (roomsData.length > 0) {
- setFilters((prev) => ({ ...prev, roomId: roomsData[0]._id }));
- setBookingForm((prev) => ({ ...prev, roomId: roomsData[0]._id }));
- }
- } catch (err) {
- toast.error(
- err.response?.data?.message ||
- "Failed to load room booking data. Please refresh.",
- );
- } finally {
- setLoading(false);
- }
- };
+// ─── Drag-and-Select Timeline ─────────────────────────────────────────────────
- initialize();
- }, [userRole, username]);
+const DragTimeline = ({ bookings, date, onSlotSelect, clashBookingId }) => {
+ const trackRef = useRef(null);
+ const [dragging, setDragging] = useState(false);
+ const [dragStart, setDragStart] = useState(null);
+ const [dragEnd, setDragEnd] = useState(null);
+ const [hoveredHour, setHoveredHour] = useState(null);
+ const totalHours = HOUR_END - HOUR_START;
+ const totalWidth = totalHours * HOUR_WIDTH_PX;
- const selectedRoomName = useMemo(() => {
- const selectedRoom = rooms.find((room) => room._id === filters.roomId);
- return selectedRoom?.name || "Selected room";
- }, [rooms, filters.roomId]);
+ const getClientX = (e) => (e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX);
- const availabilityBookings = useMemo(() => {
- return bookings
- .filter((booking) => {
- if (!filters.roomId || booking.room?._id !== filters.roomId) return false;
- if (!["Pending", "Approved"].includes(booking.status)) return false;
- return sameDay(booking.startTime, filters.date);
- })
- .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
- }, [bookings, filters.roomId, filters.date]);
+ const xToHour = (clientX) => {
+ if (!trackRef.current) return HOUR_START;
+ const rect = trackRef.current.getBoundingClientRect();
+ const x = clientX - rect.left + trackRef.current.scrollLeft;
+ return Math.max(HOUR_START, Math.min(HOUR_END, HOUR_START + x / HOUR_WIDTH_PX));
+ };
- const upcomingByDate = useMemo(() => {
- if (!filters.roomId) return {};
- const todayStart = getTodayStart();
+ const roundHalf = (h) => Math.round(h * 2) / 2;
- const grouped = bookings
- .filter((booking) => {
- if (booking.room?._id !== filters.roomId) return false;
- if (!["Pending", "Approved"].includes(booking.status)) return false;
- return new Date(booking.startTime) >= todayStart;
- })
- .sort((a, b) => new Date(a.startTime) - new Date(b.startTime))
- .reduce((acc, booking) => {
- const key = toDateInput(new Date(booking.startTime));
- if (!acc[key]) acc[key] = [];
- acc[key].push(booking);
- return acc;
- }, {});
+ const onDragStart = (e) => {
+ // Only block default if it's a mouse event to keep scrolling smooth on mobile
+ if (!e.touches && e.button !== 0) return;
+ const h = roundHalf(xToHour(getClientX(e)));
+ setDragging(true);
+ setDragStart(h);
+ setDragEnd(h);
+ };
- return grouped;
- }, [bookings, filters.roomId]);
+ const onDragMove = useCallback(
+ (e) => {
+ if (!trackRef.current) return;
+ const h = roundHalf(xToHour(getClientX(e)));
+ setHoveredHour(h);
+ if (dragging) setDragEnd(h);
+ },
+ [dragging]
+ );
- const clashWarning = useMemo(() => {
- if (
- !bookingForm.roomId ||
- !bookingForm.date ||
- !bookingForm.startTime ||
- !bookingForm.endTime
- ) {
- return null;
+ const onDragEnd = useCallback(() => {
+ if (!dragging) return;
+ setDragging(false);
+ if (dragStart === null || dragEnd === null) return;
+ const s = Math.min(dragStart, dragEnd);
+ const e = Math.max(dragStart, dragEnd);
+ if (e - s < 0.25) return;
+ const pad = (n) => String(Math.floor(n)).padStart(2, "0");
+ const minStr = (h) => (h % 1 === 0.5 ? "30" : "00");
+ const startStr = `${pad(s)}:${minStr(s)}`;
+ const endStr = `${pad(e)}:${minStr(e)}`;
+ onSlotSelect(startStr, endStr);
+ setDragStart(null);
+ setDragEnd(null);
+ }, [dragging, dragStart, dragEnd, onSlotSelect]);
+
+ const onMouseLeave = () => {
+ setHoveredHour(null);
+ if (dragging) {
+ setDragging(false);
+ setDragStart(null);
+ setDragEnd(null);
}
+ };
- const start = combineDateTime(bookingForm.date, bookingForm.startTime);
- const end = combineDateTime(bookingForm.date, bookingForm.endTime);
- if (end <= start) return null;
-
- return bookings.find((booking) => {
- if (booking.room?._id !== bookingForm.roomId) return false;
- if (!sameDay(booking.startTime, bookingForm.date)) return false;
- return isClashing(booking, start, end);
- });
- }, [bookings, bookingForm]);
+ const bars = bookings.map((booking) => {
+ const start = new Date(booking.startTime);
+ const end = new Date(booking.endTime);
+ const startH = start.getHours() + start.getMinutes() / 60;
+ const endH = end.getHours() + end.getMinutes() / 60;
+ const left = (startH - HOUR_START) * HOUR_WIDTH_PX;
+ const width = (endH - startH) * HOUR_WIDTH_PX;
+ const isClash = clashBookingId === booking._id;
+ return { booking, left, width, isClash, startH, endH };
+ });
- const myRequests = useMemo(() => {
- return bookings
- .filter((booking) =>
- String(booking.bookedBy?._id || booking.bookedBy) === String(userId),
- )
- .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- }, [bookings, userId]);
+ const selLeft = dragStart !== null && dragEnd !== null
+ ? (Math.min(dragStart, dragEnd) - HOUR_START) * HOUR_WIDTH_PX : 0;
+ const selWidth = dragStart !== null && dragEnd !== null
+ ? Math.abs(dragEnd - dragStart) * HOUR_WIDTH_PX : 0;
- const pendingForApproval = useMemo(() => {
- return bookings
- .filter((booking) => booking.status === "Pending")
- .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
- }, [bookings]);
+ return (
+
+
+ {/* Hour labels */}
+
+ {Array.from({ length: totalHours + 1 }, (_, i) => (
+
0 ? "1px dashed #e2e8f0" : "none" }}
+ >
+ {String(HOUR_START + i).padStart(2, "0")}:00
+
+ ))}
+
- const timeSlots = useMemo(() => {
- const slots = [];
- for (let hour = 8; hour <= 21; hour += 1) {
- const label = `${String(hour).padStart(2, "0")}:00 - ${String(hour + 1).padStart(2, "0")}:00`;
- const slotStart = combineDateTime(filters.date, `${String(hour).padStart(2, "0")}:00`);
- const slotEnd = combineDateTime(filters.date, `${String(hour + 1).padStart(2, "0")}:00`);
+
+ {/* Background grid lines */}
+ {Array.from({ length: totalHours + 1 }, (_, i) => (
+
+ ))}
+
+ {/* Hover line */}
+ {hoveredHour !== null && !dragging && (
+
+ )}
- const booking = availabilityBookings.find((entry) =>
- isClashing(entry, slotStart, slotEnd),
- );
+ {/* Drag selection highlight */}
+ {dragging && dragStart !== null && dragEnd !== null && selWidth > 0 && (
+
+ )}
- slots.push({ label, booking });
- }
- return slots;
- }, [availabilityBookings, filters.date]);
+ {/* Booking bars */}
+ {bars.map(({ booking, left, width, isClash }) => (
+
+
+ {booking.event?.title || booking.purpose || "Booking"}
+
+
+ {formatTime(booking.startTime)}–{formatTime(booking.endTime)}
+
+
+ {booking.bookedBy?.personal_info?.name || booking.bookedBy?.username || ""}
+
+
+ ))}
- const addAmenityChip = () => {
- const value = roomForm.customAmenity.trim();
- if (!value) return;
+ {/* Empty state hint */}
+ {bars.length === 0 && (
+
+ Drag horizontally across the timeline to request a booking slot
+
+ )}
+
+
- if (roomForm.amenities.some((amenity) => amenity.toLowerCase() === value.toLowerCase())) {
- toast.info("Amenity already added.");
- return;
- }
+
+
+ );
+};
- setRoomForm((prev) => ({
- ...prev,
- amenities: [...prev.amenities, value],
- customAmenity: "",
- }));
- };
+// ─── Rejection Modal ──────────────────────────────────────────────────────────
- const toggleAmenity = (amenity) => {
- setRoomForm((prev) => {
- const exists = prev.amenities.includes(amenity);
- return {
- ...prev,
- amenities: exists
- ? prev.amenities.filter((item) => item !== amenity)
- : [...prev.amenities, amenity],
- };
- });
+const RejectionModal = ({ open, onClose, onConfirm }) => {
+ const [reason, setReason] = useState("");
+ const handleConfirm = () => {
+ onConfirm(reason.trim());
+ setReason("");
};
+ return (
+
+
+
+
+
Provide an optional reason for rejection. This will be visible to the requester to help them adjust their request.
+
+
+
+
+
+
+
+
+
+
+ );
+};
- const removeAmenity = (amenity) => {
- setRoomForm((prev) => ({
- ...prev,
- amenities: prev.amenities.filter((item) => item !== amenity),
- }));
- };
+// ─── Booking Modal ────────────────────────────────────────────────────────────
+
+const BookingModal = ({
+ open,
+ onClose,
+ rooms,
+ events,
+ bookings,
+ canBook,
+ onSubmit,
+ submitting,
+ initialData,
+ onClashChange, // Function to notify parent of current clash ID
+}) => {
+ const [form, setForm] = useState({
+ roomId: rooms[0]?._id || "",
+ eventId: "",
+ date: toDateInput(new Date()),
+ startTime: "10:00",
+ endTime: "11:00",
+ purpose: "",
+ });
- const handleCreateRoom = async (event) => {
- event.preventDefault();
+ // Reset form or load initial data when modal opens/closes
+ useEffect(() => {
+ if (open) {
+ if (initialData) {
+ setForm((prev) => ({ ...prev, ...initialData }));
+ } else {
+ setForm({
+ roomId: rooms[0]?._id || "",
+ eventId: "",
+ date: toDateInput(new Date()),
+ startTime: "10:00",
+ endTime: "11:00",
+ purpose: "",
+ });
+ }
+ }
+ }, [open, initialData, rooms]);
- try {
- setCreatingRoom(true);
+ const start = useMemo(
+ () => combineDateTime(form.date, form.startTime),
+ [form.date, form.startTime]
+ );
+ const end = useMemo(
+ () => combineDateTime(form.date, form.endTime),
+ [form.date, form.endTime]
+ );
- await api.post("/api/rooms/create-room", {
- name: roomForm.name.trim(),
- capacity: Number(roomForm.capacity),
- location: roomForm.location.trim(),
- amenities: roomForm.amenities,
- });
+ const clashWarning = useMemo(() => {
+ if (!form.roomId || !form.date || !form.startTime || !form.endTime) return null;
+ if (isNaN(start) || isNaN(end) || end <= start) return null;
+ return bookings.find((b) => {
+ if (b.room?._id !== form.roomId) return false;
+ if (!sameDay(b.startTime, form.date)) return false;
+ return isClashing(b, start, end);
+ });
+ }, [bookings, form, start, end]);
- setRoomForm({
- name: "",
- capacity: "",
- location: "",
- amenities: [],
- customAmenity: "",
- });
- setShowRoomForm(false);
- toast.success("Room created successfully.");
- await refreshData();
- } catch (err) {
- toast.error(err.response?.data?.message || "Failed to create room.");
- } finally {
- setCreatingRoom(false);
+ // Sync clash state with parent (for timeline highlighting)
+ useEffect(() => {
+ if (onClashChange) {
+ onClashChange(clashWarning ? clashWarning._id : null);
}
+ return () => onClashChange && onClashChange(null);
+ }, [clashWarning, onClashChange]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!form.roomId) return toast.error("Please select a room.");
+ if (isNaN(start.getTime()) || isNaN(end.getTime()))
+ return toast.error("Please enter valid date/time.");
+ if (end <= start) return toast.error("End time must be after start time.");
+ if (start < new Date()) return toast.error("Cannot book a past date/time.");
+ if (clashWarning) return toast.error("This slot overlaps with an existing booking.");
+ onSubmit(form);
};
- const handleSubmitBooking = async (event) => {
- event.preventDefault();
-
- if (!bookingForm.roomId) {
- toast.error("Please select a room.");
- return;
- }
+ return (
+
+
+
+ );
+};
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+const RoomBooking = () => {
+ const { isUserLoggedIn } = useContext(AdminContext);
+ const userRole = isUserLoggedIn?.role || "STUDENT";
+ const username = isUserLoggedIn?.username || "";
+ const userId = isUserLoggedIn?._id;
+
+ const canBook = ADMIN_ROLES.includes(userRole);
+ const canApprove = userRole === APPROVAL_UI_ROLE;
+ const showMyRequests = canBook && !canApprove;
+
+ const [rooms, setRooms] = useState([]);
+ const [events, setEvents] = useState([]);
+ const [bookings, setBookings] = useState([]);
+
+ const [loading, setLoading] = useState(true);
+ const [submittingBooking, setSubmittingBooking] = useState(false);
+ const [creatingRoom, setCreatingRoom] = useState(false);
+
+ const [activeTab, setActiveTab] = useState("availability");
+
+ // Admin internal tab
+ const [adminView, setAdminView] = useState("pending"); // "pending" | "history"
+
+ // Modals
+ const [bookingModalOpen, setBookingModalOpen] = useState(false);
+ const [bookingModalInitial, setBookingModalInitial] = useState(null);
+ const [createRoomModalOpen, setCreateRoomModalOpen] = useState(false);
+ const [rejectionTarget, setRejectionTarget] = useState(null);
+
+ const [filters, setFilters] = useState({
+ roomId: "",
+ date: toDateInput(new Date()),
+ status: "",
+ minCapacity: "",
+ amenities: [],
+ });
+
+ const fetchEvents = useCallback(async () => {
+ if (!userRole || userRole === "STUDENT") return [];
+ let url = `/api/events/by-role/${userRole}`;
+ if (userRole === "CLUB_COORDINATOR" && username) {
+ url += `?username=${encodeURIComponent(username)}`;
}
+ const res = await api.get(url);
+ return res.data || [];
+ }, [userRole, username]);
- if (start < new Date()) {
- toast.error("You cannot submit a booking request for past date/time.");
- return;
+ const fetchRooms = async () => {
+ const res = await api.get("/api/rooms/rooms");
+ return res.data || [];
+ };
+
+ const fetchBookings = async () => {
+ const res = await api.get("/api/rooms/bookings");
+ return res.data || [];
+ };
+
+ const refreshData = async () => {
+ try {
+ const [roomsData, bookingsData, eventsData] = await Promise.all([
+ fetchRooms(),
+ fetchBookings(),
+ fetchEvents(),
+ ]);
+ setRooms(roomsData);
+ setBookings(bookingsData);
+ setEvents(eventsData);
+
+ if (!filters.roomId && roomsData.length > 0) {
+ setFilters((prev) => ({ ...prev, roomId: roomsData[0]._id }));
+ }
+ } catch (err) {
+ toast.error(err.response?.data?.message || "Failed to refresh data.");
}
+ };
- if (clashWarning) {
- toast.error("This slot overlaps with an existing booking.");
- return;
+ useEffect(() => {
+ const initialize = async () => {
+ try {
+ setLoading(true);
+ const [roomsData, eventsData, bookingsData] = await Promise.all([
+ fetchRooms(),
+ fetchEvents(),
+ fetchBookings(),
+ ]);
+ setRooms(roomsData);
+ setEvents(eventsData);
+ setBookings(bookingsData);
+ if (roomsData.length > 0) {
+ setFilters((prev) => ({ ...prev, roomId: roomsData[0]._id }));
+ }
+ } catch (err) {
+ toast.error(err.response?.data?.message || "Failed to load data.");
+ } finally {
+ setLoading(false);
+ }
+ };
+ initialize();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userRole, username]);
+
+ const filteredRooms = useMemo(() => {
+ return rooms.filter((room) => {
+ if (filters.minCapacity && room.capacity < Number(filters.minCapacity)) return false;
+ if (
+ filters.amenities.length > 0 &&
+ !filters.amenities.every((a) => room.amenities?.includes(a))
+ )
+ return false;
+ return true;
+ });
+ }, [rooms, filters.minCapacity, filters.amenities]);
+
+ const availabilityBookings = useMemo(() => {
+ return bookings.filter((b) => {
+ if (!filters.roomId || b.room?._id !== filters.roomId) return false;
+ if (!["Pending", "Approved"].includes(b.status)) return false;
+ return sameDay(b.startTime, filters.date);
+ });
+ }, [bookings, filters.roomId, filters.date]);
+
+ const [timelineClashId, setTimelineClashId] = useState(null);
+
+ const upcomingByDate = useMemo(() => {
+ if (!filters.roomId) return {};
+ const todayStart = getTodayStart();
+ return bookings
+ .filter((b) => {
+ if (b.room?._id !== filters.roomId) return false;
+ if (!["Pending", "Approved"].includes(b.status)) return false;
+ return new Date(b.startTime) >= todayStart;
+ })
+ .sort((a, b) => new Date(a.startTime) - new Date(b.startTime))
+ .reduce((acc, b) => {
+ const key = toDateInput(new Date(b.startTime));
+ if (!acc[key]) acc[key] = [];
+ acc[key].push(b);
+ return acc;
+ }, {});
+ }, [bookings, filters.roomId]);
+
+ const myRequests = useMemo(() => {
+ return bookings
+ .filter((b) => String(b.bookedBy?._id || b.bookedBy) === String(userId))
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+ }, [bookings, userId]);
+
+ const pendingForApproval = useMemo(() => {
+ return bookings
+ .filter((b) => b.status === "Pending")
+ .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
+ }, [bookings]);
+
+ const approvalHistory = useMemo(() => {
+ return bookings
+ .filter((b) => ["Approved", "Rejected", "Cancelled"].includes(b.status))
+ .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
+ }, [bookings]);
+
+ const selectedRoom = useMemo(
+ () => rooms.find((r) => r._id === filters.roomId),
+ [rooms, filters.roomId]
+ );
+
+ const handleCreateRoom = async (formData) => {
+ try {
+ setCreatingRoom(true);
+ await api.post("/api/rooms/create-room", {
+ name: formData.name.trim(),
+ capacity: Number(formData.capacity),
+ location: formData.location.trim(),
+ amenities: formData.amenities,
+ });
+ setCreateRoomModalOpen(false);
+ toast.success("Room created successfully.");
+ await refreshData();
+ } catch (err) {
+ toast.error(err.response?.data?.message || "Failed to create room.");
+ } finally {
+ setCreatingRoom(false);
}
+ };
+ const handleSubmitBooking = async (form) => {
+ const start = combineDateTime(form.date, form.startTime);
+ const end = combineDateTime(form.date, form.endTime);
try {
setSubmittingBooking(true);
-
const payload = {
- roomId: bookingForm.roomId,
- date: new Date(`${bookingForm.date}T00:00:00`),
+ roomId: form.roomId,
+ date: new Date(`${form.date}T00:00:00`),
startTime: start,
endTime: end,
- purpose: bookingForm.purpose.trim(),
+ purpose: form.purpose.trim(),
};
-
- if (bookingForm.eventId) {
- payload.eventId = bookingForm.eventId;
- }
-
+ if (form.eventId) payload.eventId = form.eventId;
await api.post("/api/rooms/book", payload);
-
- toast.success("Room booking request submitted successfully.");
- setFilters((prev) => ({
- ...prev,
- roomId: bookingForm.roomId,
- date: bookingForm.date,
- }));
- setBookingForm((prev) => ({
- ...prev,
- eventId: "",
- purpose: "",
- }));
-
+ toast.success("Room booking request submitted.");
+ setBookingModalOpen(false);
+ setBookingModalInitial(null);
+ setFilters((prev) => ({ ...prev, roomId: form.roomId, date: form.date }));
const bookingsData = await fetchBookings();
setBookings(bookingsData);
- setActiveTab(showMyRequests ? "myRequests" : "availability");
+ setActiveTab("availability");
} catch (err) {
toast.error(err.response?.data?.message || "Failed to submit booking.");
} finally {
@@ -446,632 +994,645 @@ const RoomBooking = () => {
}
};
- const handleReviewBooking = async (bookingId, status) => {
+ const handleApproveBooking = async (bookingId) => {
try {
- await api.put(`/api/rooms/bookings/${bookingId}/status`, { status });
+ await api.put(`/api/rooms/bookings/${bookingId}/status`, { status: "Approved" });
+ setBookings(await fetchBookings());
+ toast.success("Booking approved.");
+ } catch (err) {
+ toast.error(err.response?.data?.message || "Failed to approve booking.");
+ }
+ };
- const bookingsData = await fetchBookings();
- setBookings(bookingsData);
- toast.success(`Booking ${status.toLowerCase()} successfully.`);
+ const openRejectionModal = (bookingId) => setRejectionTarget(bookingId);
+
+ const handleConfirmRejection = async (reason) => {
+ if (!rejectionTarget) return;
+ try {
+ await api.put(`/api/rooms/bookings/${rejectionTarget}/status`, {
+ status: "Rejected",
+ ...(reason ? { rejectionReason: reason } : {}),
+ });
+ setBookings(await fetchBookings());
+ toast.success("Booking rejected.");
} catch (err) {
- toast.error(
- err.response?.data?.message || "Failed to update booking status.",
- );
+ toast.error(err.response?.data?.message || "Failed to reject booking.");
+ } finally {
+ setRejectionTarget(null);
}
};
const handleCancelBooking = async (bookingId) => {
try {
await api.delete(`/api/rooms/bookings/${bookingId}`);
-
- const bookingsData = await fetchBookings();
- setBookings(bookingsData);
- toast.success("Booking cancelled successfully.");
+ setBookings(await fetchBookings());
+ toast.success("Booking cancelled.");
} catch (err) {
toast.error(err.response?.data?.message || "Failed to cancel booking.");
}
};
+ const handleSlotSelect = useCallback(
+ (startTime, endTime) => {
+ if (!canBook) return;
+ setBookingModalInitial({
+ roomId: filters.roomId || rooms[0]?._id || "",
+ date: filters.date,
+ startTime,
+ endTime,
+ eventId: "",
+ purpose: "",
+ });
+ setBookingModalOpen(true);
+ },
+ [canBook, filters.roomId, filters.date, rooms]
+ );
+
+ const toggleFilterAmenity = (amenity) =>
+ setFilters((prev) => ({
+ ...prev,
+ amenities: prev.amenities.includes(amenity)
+ ? prev.amenities.filter((a) => a !== amenity)
+ : [...prev.amenities, amenity],
+ }));
+
if (loading) {
return (
-
-
-
+
);
}
return (
-
-
-
-
- Smart Room Booking
-
-
- Request rooms, explore day-wise room timelines, and manage your
- bookings in one place.
-
-
-
-
- {canBook && (
+ <>
+
{ setBookingModalOpen(false); setBookingModalInitial(null); }}
+ rooms={filteredRooms.length > 0 ? filteredRooms : rooms}
+ events={events}
+ bookings={bookings}
+ canBook={canBook}
+ onSubmit={handleSubmitBooking}
+ submitting={submittingBooking}
+ initialData={bookingModalInitial}
+ onClashChange={setTimelineClashId}
+ />
+
+ setCreateRoomModalOpen(false)}
+ onSubmit={handleCreateRoom}
+ submitting={creatingRoom}
+ />
+
+ setRejectionTarget(null)}
+ onConfirm={handleConfirmRejection}
+ />
+
+
+
+ {/* Header section */}
+
+
+
Smart Room Booking
+
+ Drag the timeline to select an empty slot and reserve a room instantly. Filter by capacity or amenities to find your perfect space.
+
+
+
+ {canBook && (
+
+)}
+
+{userRole === "PRESIDENT" && (
+
+)}
- )}
-
-
-
-
- {showRoomForm && canBook && (
-
-
-
-
- {PRESET_AMENITIES.map((amenity) => {
- const selected = roomForm.amenities.includes(amenity);
- return (
-
- );
- })}
-
-
-
-
- setRoomForm((prev) => ({
- ...prev,
- customAmenity: event.target.value,
- }))
- }
- className="rounded-lg border border-slate-300 px-3 py-2 text-sm flex-1"
- />
-
-
+ {/* Main Navigation */}
+
+
+ {canApprove && (
+
+ )}
+ {showMyRequests && (
+
+ )}
+
- {roomForm.amenities.length > 0 && (
-
- {roomForm.amenities.map((amenity) => (
-
+ {/* Filters Box */}
+
+
+ Filter Spaces
+
+
+
+
+
+
+
+
+
+
-
- ))}
+ setFilters((p) => ({ ...p, date: e.target.value }))}
+ className="flex-1 w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 transition-all bg-white"
+ />
+
+
+
+
+
+ setFilters((p) => ({ ...p, minCapacity: e.target.value }))}
+ className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 transition-all bg-white"
+ />
+
+
+
+ {/* Amenities Toggles */}
+
+
+ Amenities required:
+ {PRESET_AMENITIES.map((amenity) => {
+ const active = filters.amenities.includes(amenity);
+ return (
+
+ );
+ })}
+ {filters.amenities.length > 0 && (
+
+ )}
+
- )}
-
-
-
-
-
-
- )}
-
-
-
-
- {canApprove && (
-
- )}
- {showMyRequests && (
-
- )}
-
-
- {activeTab === "request" && (
-