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 ( + +
+
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} + className="w-full rounded-md border border-slate-300 px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 transition-all bg-white" + required + /> +
+
+ + setForm((p) => ({ ...p, capacity: e.target.value }))} + className="w-full rounded-md border border-slate-300 px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 transition-all bg-white" + required + /> +
+
+ + setForm((p) => ({ ...p, location: e.target.value }))} + className="w-full rounded-md border border-slate-300 px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 transition-all bg-white" + required + /> +
+
- const [roomsData, eventsData, bookingsData] = await Promise.all([ - fetchRooms(), - eventsRequest, - fetchBookings(), - ]); +
+ +
+ {PRESET_AMENITIES.map((amenity) => { + const selected = form.amenities.includes(amenity); + return ( + + ); + })} +
+
+ setForm((p) => ({ ...p, customAmenity: e.target.value }))} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addAmenityChip())} + className="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm flex-1 focus:outline-none focus:ring-2 focus:ring-slate-500/20 focus:border-slate-500 transition-all bg-white" + /> + +
+ {form.amenities.length > 0 && ( +
+ {form.amenities.map((amenity) => ( + + {amenity} + + + ))} +
+ )} +
- setRooms(roomsData); - setEvents(eventsData); - setBookings(bookingsData); +
+ +
+
+
+ ); +}; - 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.

+
+
+ +