diff --git a/backend/controllers/roomBookingController.js b/backend/controllers/roomBookingController.js new file mode 100644 index 00000000..d19c11ba --- /dev/null +++ b/backend/controllers/roomBookingController.js @@ -0,0 +1,126 @@ + +const { Room, RoomBooking, Event, User } = require('../models/schema'); + +exports.createRoom = async (req, res) => { + try { + const { name, capacity, location, amenities } = req.body; + const room = new Room({ name, capacity, location, amenities }); + await room.save(); + res.status(201).json({ message: 'Room created', room }); + } catch (err) { + if (err.code === 11000) { + return res.status(409).json({ message: 'Room name already exists' }); + } + res.status(500).json({ message: 'Error creating room', error: err.message }); + } +}; + + +exports.getAllRooms = async (_req, res) => { + try { + const rooms = await Room.find({ is_active: true }); + res.json(rooms); + } catch (err) { + res.status(500).json({ message: 'Error fetching rooms' }); + } +}; + + +exports.bookRoom = async (req, res) => { + try { + const { roomId, eventId, date, startTime, endTime, purpose } = req.body; + // Check for clash + const clash = await RoomBooking.findOne({ + room: roomId, + status: { $in: ['Pending', 'Approved'] }, + $or: [ + { startTime: { $lt: endTime }, endTime: { $gt: startTime } }, + ], + }); + if (clash) { + return res.status(409).json({ message: 'Room clash detected', conflictingBooking: clash }); + } + const booking = new RoomBooking({ + room: roomId, + event: eventId, + date, + startTime, + endTime, + purpose, + bookedBy: req.user._id, + }); + await booking.save(); + res.status(201).json({ message: 'Room booked (pending approval)', booking }); + } catch (err) { + res.status(500).json({ message: 'Error booking room', error: err.message }); + } +}; + + +exports.getAvailability = async (req, res) => { + try { + const { date, roomId } = req.query; + const query = { date: new Date(date) }; + if (roomId) query.room = roomId; + const bookings = await RoomBooking.find(query).populate('room event bookedBy'); + res.json(bookings); + } catch (err) { + res.status(500).json({ message: 'Error fetching availability' }); + } +}; + + +exports.updateBookingStatus = async (req, res) => { + try { + const { id } = req.params; + const { status } = req.body; + if (!['Approved', 'Rejected'].includes(status)) { + return res.status(400).json({ message: 'Invalid status' }); + } + const booking = await RoomBooking.findByIdAndUpdate( + id, + { status, reviewedBy: req.user._id, updated_at: new Date() }, + { new: true } + ); + if (!booking) return res.status(404).json({ message: 'Booking not found' }); + res.json({ message: 'Booking status updated', booking }); + } catch (err) { + res.status(500).json({ message: 'Error updating booking status' }); + } +}; + + +exports.cancelBooking = async (req, res) => { + try { + const { id } = req.params; + const booking = await RoomBooking.findById(id); + if (!booking) return res.status(404).json({ message: 'Booking not found' }); + + if ( + String(booking.bookedBy) !== String(req.user._id) && + !['PRESIDENT', 'GENSEC_SCITECH', 'GENSEC_ACADEMIC', 'GENSEC_CULTURAL', 'GENSEC_SPORTS', 'CLUB_COORDINATOR'].includes(req.user.role) + ) { + return res.status(403).json({ message: 'Forbidden' }); + } + booking.status = 'Cancelled'; + booking.updated_at = new Date(); + await booking.save(); + res.json({ message: 'Booking cancelled', booking }); + } catch (err) { + res.status(500).json({ message: 'Error cancelling booking' }); + } +}; + +exports.getBookings = async (req, res) => { + try { + const { roomId, date, status } = req.query; + const query = {}; + if (roomId) query.room = roomId; + if (date) query.date = new Date(date); + if (status) query.status = status; + const bookings = await RoomBooking.find(query).populate('room event bookedBy'); + res.json(bookings); + } catch (err) { + res.status(500).json({ message: 'Error fetching bookings' }); + } +}; diff --git a/backend/index.js b/backend/index.js index 4ad6d419..78e133d7 100644 --- a/backend/index.js +++ b/backend/index.js @@ -21,6 +21,7 @@ const dashboardRoutes = require("./routes/dashboard.js"); const analyticsRoutes = require("./routes/analytics.js"); const porRoutes = require("./routes/por.js"); +const roomBookingRoutes = require("./routes/roomBooking.js"); const app = express(); if (process.env.NODE_ENV === "production") { @@ -68,6 +69,7 @@ app.use("/api/announcements", announcementRoutes); app.use("/api/dashboard", dashboardRoutes); app.use("/api/announcements", announcementRoutes); app.use("/api/analytics", analyticsRoutes); +app.use("/api/rooms", roomBookingRoutes); app.use("/api/por", porRoutes); // Start the server diff --git a/backend/models/schema.js b/backend/models/schema.js index 400bc856..0b85f5d6 100644 --- a/backend/models/schema.js +++ b/backend/models/schema.js @@ -645,6 +645,94 @@ const OrganizationalUnit = mongoose.model( ); const Announcement = mongoose.model("Announcement", announcementSchema); +const roomSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + unique: true, + }, + capacity: { + type: Number, + required: true, + }, + location: { + type: String, + required: true, + }, + amenities: [ + { + type: String, + }, + ], + is_active: { + type: Boolean, + default: true, + }, + created_at: { + type: Date, + default: Date.now, + }, + updated_at: { + type: Date, + default: Date.now, + }, +}); + +const Room = mongoose.model("Room", roomSchema); + +const roomBookingSchema = new mongoose.Schema({ + room: { + type: mongoose.Schema.Types.ObjectId, + ref: "Room", + required: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: "Event", + }, + date: { + type: Date, + required: true, + }, + startTime: { + type: Date, + required: true, + }, + endTime: { + type: Date, + required: true, + }, + purpose: { + type: String, + }, + bookedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + status: { + type: String, + enum: ["Pending", "Approved", "Rejected", "Cancelled"], + default: "Pending", + }, + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + created_at: { + type: Date, + default: Date.now, + }, + updated_at: { + type: Date, + default: Date.now, + }, +}); + +roomBookingSchema.index({ room: 1, date: 1, startTime: 1, endTime: 1 }); + +const RoomBooking = mongoose.model("RoomBooking", roomBookingSchema); + module.exports = { User, Feedback, @@ -656,4 +744,6 @@ module.exports = { Position, OrganizationalUnit, Announcement, + Room, + RoomBooking, }; diff --git a/backend/routes/roomBooking.js b/backend/routes/roomBooking.js new file mode 100644 index 00000000..7ff148fc --- /dev/null +++ b/backend/routes/roomBooking.js @@ -0,0 +1,35 @@ +const express = require('express'); +const router = express.Router(); +const isAuthenticated = require('../middlewares/isAuthenticated'); +const authorizeRole = require('../middlewares/authorizeRole'); +const { ROLE_GROUPS, ROLES } = require('../utils/roles'); +const roomBookingController = require('../controllers/roomBookingController'); + +// Create a new room (admin only) +router.post('/create-room', isAuthenticated, authorizeRole(ROLE_GROUPS.ADMIN), roomBookingController.createRoom); + +// Get all rooms +router.get('/rooms', isAuthenticated, roomBookingController.getAllRooms); + +// Book a room (admin only) +router.post('/book', isAuthenticated, authorizeRole(ROLE_GROUPS.ADMIN), roomBookingController.bookRoom); + +// Get room availability +router.get('/availability', isAuthenticated, roomBookingController.getAvailability); + +// Get bookings (filterable) +router.get('/bookings', isAuthenticated, roomBookingController.getBookings); + +// Update booking status (approve/reject) +router.put('/bookings/:id/status', isAuthenticated, authorizeRole([ + ROLES.PRESIDENT, + ROLES.GENSEC_SCITECH, + ROLES.GENSEC_ACADEMIC, + ROLES.GENSEC_CULTURAL, + ROLES.GENSEC_SPORTS, +]), roomBookingController.updateBookingStatus); + +// Cancel a booking +router.delete('/bookings/:id', isAuthenticated, roomBookingController.cancelBooking); + +module.exports = router; diff --git a/backend/seed.js b/backend/seed.js index 61a65273..3656e6cf 100644 --- a/backend/seed.js +++ b/backend/seed.js @@ -2,17 +2,34 @@ require("dotenv").config(); const mongoose = require("mongoose"); const { - User, - Feedback, - Achievement, - UserSkill, - Skill, - Event, - PositionHolder, - Position, - OrganizationalUnit, + User, + Feedback, + Achievement, + UserSkill, + Skill, + Event, + PositionHolder, + Position, + OrganizationalUnit, + Room, } = require("./models/schema"); +// Sample Rooms for Seeding +const sampleRooms = [ + { name: "LH-101", capacity: 60, location: "Academic Block 1, Ground Floor", amenities: ["Projector", "AC", "Whiteboard"] }, + { name: "LH-102", capacity: 60, location: "Academic Block 1, Ground Floor", amenities: ["Projector", "AC"] }, + { name: "Seminar Hall", capacity: 120, location: "Admin Block, 1st Floor", amenities: ["Projector", "Sound System", "AC"] }, +]; + +// Seeds sample rooms for testing room booking features. + +const seedRooms = async () => { + console.log("Seeding sample rooms..."); + await Room.deleteMany({}); + await Room.insertMany(sampleRooms); + console.log("Sample rooms seeded!"); +}; + // --- Data for Seeding --- // Original club/committee data. diff --git a/frontend/src/Components/RoomBooking.jsx b/frontend/src/Components/RoomBooking.jsx index 9a351cca..d8393adf 100644 --- a/frontend/src/Components/RoomBooking.jsx +++ b/frontend/src/Components/RoomBooking.jsx @@ -1,173 +1,885 @@ -// import { useState, useEffect } from "react"; -// import axios from "axios"; - -// const API_BASE_URL = process.env.REACT_APP_BACKEND_URL|| "http://localhost:8000"; - -// export default function RoomBooking() { -// const [form, setForm] = useState({ -// date: "", -// time: "", -// room: "", -// description: "", -// }); -// const [bookings, setBookings] = useState([]); -// const [message, setMessage] = useState(""); - -// useEffect(() => { -// fetchBookings(); -// }, []); - -// const fetchBookings = async () => { -// try { -// const res = await axios.get(`${API_BASE_URL}/room/requests`); -// setBookings(res.data); -// } catch (error) { -// console.error("Error fetching bookings:", error); -// } -// }; - -// const handleSubmit = async (e) => { -// e.preventDefault(); -// try { -// await axios.post(`${API_BASE_URL}/room/request`, form); -// setMessage("Booking request submitted!"); -// setForm({ date: "", time: "", room: "", description: "" }); -// fetchBookings(); -// } catch (error) { -// console.error("Error submitting booking:", error); -// setMessage("Failed to submit request."); -// } -// }; - -// return ( -//
{message}
-//| Date | -//Time | -//Room | -//Status | -//
|---|---|---|---|
| No bookings found | -//|||
| {b.date} | -//{b.time} | -//{b.room} | -//-// -// {b.status} -// -// | -//
- This component's function is added in the events page, now president can - manage the booking requests on events page. -
+ date.getFullYear() === year && + date.getMonth() + 1 === month && + date.getDate() === day + ); +}; + +const isClashing = (existingBooking, startTime, endTime) => { + 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; +}; + +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", +}; + +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 canReview = APPROVAL_ROLES.includes(userRole); + + 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 [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + const [showRoomForm, setShowRoomForm] = useState(false); + const [roomForm, setRoomForm] = useState({ + name: "", + capacity: "", + location: "", + amenities: "", + }); + + 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 || []; + }; + + const fetchBookings = async () => { + const response = await api.get("/api/rooms/bookings"); + return response.data || []; + }; + + const refreshData = async () => { + try { + setError(""); + const [roomsData, bookingsData] = await Promise.all([ + fetchRooms(), + fetchBookings(), + ]); + + setRooms(roomsData); + setBookings(bookingsData); + + 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) { + setError( + err.response?.data?.message || "Failed to refresh room bookings.", + ); + } + }; + + useEffect(() => { + const initialize = async () => { + try { + setLoading(true); + setError(""); + + 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 || []); + } + + const [roomsData, eventsData, bookingsData] = await Promise.all([ + fetchRooms(), + eventsRequest, + fetchBookings(), + ]); + + 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) { + setError( + err.response?.data?.message || + "Failed to load room booking data. Please refresh.", + ); + } finally { + setLoading(false); + } + }; + + initialize(); + }, [userRole, username]); + + const selectedRoomName = useMemo(() => { + const selectedRoom = rooms.find((room) => room._id === filters.roomId); + return selectedRoom?.name || "Selected room"; + }, [rooms, filters.roomId]); + + const availabilityBookings = useMemo(() => { + return bookings + .filter((booking) => { + if (!filters.roomId || booking.room?._id !== filters.roomId) + 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 filteredBookings = useMemo(() => { + return bookings + .filter((booking) => { + if (filters.roomId && booking.room?._id !== filters.roomId) + return false; + if (filters.status && booking.status !== filters.status) return false; + return true; + }) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + }, [bookings, filters.roomId, filters.status]); + + const clashWarning = useMemo(() => { + if ( + !bookingForm.roomId || + !bookingForm.date || + !bookingForm.startTime || + !bookingForm.endTime + ) { + return 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 handleCreateRoom = async (event) => { + event.preventDefault(); + setError(""); + setSuccessMessage(""); + + try { + setCreatingRoom(true); + const amenities = roomForm.amenities + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + + await api.post("/api/rooms/create-room", { + name: roomForm.name.trim(), + capacity: Number(roomForm.capacity), + location: roomForm.location.trim(), + amenities, + }); + + setRoomForm({ name: "", capacity: "", location: "", amenities: "" }); + setShowRoomForm(false); + setSuccessMessage("Room created successfully."); + await refreshData(); + } catch (err) { + setError(err.response?.data?.message || "Failed to create room."); + } finally { + setCreatingRoom(false); + } + }; + + const handleSubmitBooking = async (event) => { + event.preventDefault(); + setError(""); + setSuccessMessage(""); + + if (!bookingForm.roomId) { + setError("Please select a room."); + return; + } + + const start = combineDateTime(bookingForm.date, bookingForm.startTime); + const end = combineDateTime(bookingForm.date, bookingForm.endTime); + + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + setError("Please enter valid booking date and time."); + return; + } + + if (end <= start) { + setError("End time must be after start time."); + return; + } + + if (clashWarning) { + setError( + "This slot overlaps with an existing booking. Please choose a different time.", + ); + return; + } + + try { + setSubmittingBooking(true); + + const payload = { + roomId: bookingForm.roomId, + date: new Date(`${bookingForm.date}T00:00:00`), + startTime: start, + endTime: end, + purpose: bookingForm.purpose.trim(), + }; + + if (bookingForm.eventId) { + payload.eventId = bookingForm.eventId; + } + + await api.post("/api/rooms/book", payload); + + setSuccessMessage("Room booking request submitted successfully."); + setFilters((prev) => ({ + ...prev, + roomId: bookingForm.roomId, + date: bookingForm.date, + })); + setBookingForm((prev) => ({ + ...prev, + eventId: "", + purpose: "", + })); + + const bookingsData = await fetchBookings(); + setBookings(bookingsData); + } catch (err) { + setError(err.response?.data?.message || "Failed to submit booking."); + } finally { + setSubmittingBooking(false); + } + }; + + const handleReviewBooking = async (bookingId, status) => { + try { + setError(""); + setSuccessMessage(""); + + await api.put(`/api/rooms/bookings/${bookingId}/status`, { status }); + + const bookingsData = await fetchBookings(); + setBookings(bookingsData); + setSuccessMessage(`Booking ${status.toLowerCase()} successfully.`); + } catch (err) { + setError( + err.response?.data?.message || "Failed to update booking status.", + ); + } + }; + + const handleCancelBooking = async (bookingId) => { + try { + setError(""); + setSuccessMessage(""); + + await api.delete(`/api/rooms/bookings/${bookingId}`); + + const bookingsData = await fetchBookings(); + setBookings(bookingsData); + setSuccessMessage("Booking cancelled successfully."); + } catch (err) { + setError(err.response?.data?.message || "Failed to cancel booking."); + } + }; + + if (loading) { + return ( ++ Book rooms for events, detect time clashes, and monitor day-wise + availability. +
+| Room | +Event / Purpose | +Time | +Status | +Actions | +
|---|---|---|---|---|
| + No bookings match your current filters. + | +||||
| + {booking.room?.name || "-"} + | +
+
+ {booking.event?.title ||
+ booking.purpose ||
+ "General booking"}
+
+ |
+
+ {formatDateTime(booking.startTime)}
+ + {formatDateTime(booking.endTime)} + |
+ + + {booking.status} + + | +
+
+ {canReview && booking.status === "Pending" && (
+ <>
+
+
+ >
+ )}
+
+ {canCancel &&
+ !["Cancelled", "Rejected"].includes(
+ booking.status,
+ ) && (
+
+ )}
+
+ {!canReview && booking.status === "Pending" && (
+
+ Awaiting admin review
+
+ )}
+
+ |
+