diff --git a/hab-frontend/src/App.jsx b/hab-frontend/src/App.jsx index e158c1a3..0cad53f9 100644 --- a/hab-frontend/src/App.jsx +++ b/hab-frontend/src/App.jsx @@ -11,6 +11,7 @@ import MessChangePage from "./pages/MessChangePage.jsx"; import GalaDinnerPage from "./pages/GalaDinnerPage.jsx"; import GalaDinnerDetailPage from "./pages/GalaDinnerDetailPage.jsx"; import Notifications from "./pages/Notifications.jsx"; +import MessClosurePage from "./pages/MessClosurePage.jsx"; import CreateMess from "./components/CreateMess"; import MessDetails from "./components/MessDetails"; import MessMenu from "./components/MessMenu"; @@ -94,6 +95,9 @@ function App() { path="/notifications" element={} /> + } + /> diff --git a/hab-frontend/src/apis/messClosures.js b/hab-frontend/src/apis/messClosures.js new file mode 100644 index 00000000..2d1229eb --- /dev/null +++ b/hab-frontend/src/apis/messClosures.js @@ -0,0 +1,26 @@ +import axios from "axios"; +import { BACKEND_URL } from "./server"; + +export const getAllMessClosures = async (filters = {}) => { + const params = new URLSearchParams( + Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== "" && v !== undefined)) + ).toString(); + const url = `${BACKEND_URL}/hostel/closure${params ? `?${params}` : ""}`; + const response = await axios.get(url); + return response.data; +}; + +export const createMessClosure = async (data) => { + const response = await axios.post(`${BACKEND_URL}/hostel/closure`, data); + return response.data; +}; + +export const updateMessClosure = async (id, data) => { + const response = await axios.put(`${BACKEND_URL}/hostel/closure/${id}`, data); + return response.data; +}; + +export const deleteMessClosure = async (id) => { + const response = await axios.delete(`${BACKEND_URL}/hostel/closure/${id}`); + return response.data; +}; \ No newline at end of file diff --git a/hab-frontend/src/components/Sidebar.jsx b/hab-frontend/src/components/Sidebar.jsx index 8123d6cb..0d9e3989 100644 --- a/hab-frontend/src/components/Sidebar.jsx +++ b/hab-frontend/src/components/Sidebar.jsx @@ -11,6 +11,7 @@ import { SettingOutlined, BookOutlined, NotificationOutlined, + CalendarOutlined, GiftOutlined, } from "@ant-design/icons"; @@ -40,6 +41,8 @@ const Sidebar = ({ collapsed = false, onToggle }) => { path: "/notifications", icon: , }, + { key: "8", name: "Mess Closures", path: "/mess-closures", + icon: }, ]; return ( diff --git a/hab-frontend/src/pages/MessClosurePage.jsx b/hab-frontend/src/pages/MessClosurePage.jsx new file mode 100644 index 00000000..6118e436 --- /dev/null +++ b/hab-frontend/src/pages/MessClosurePage.jsx @@ -0,0 +1,578 @@ +// HAB Admin: Schedule / View / Edit / Delete mess closures +// ───────────────────────────────────────────────────────────────────────────── + +import React, { useState, useEffect, useCallback } from "react"; +import { + getAllMessClosures, + createMessClosure, + updateMessClosure, + deleteMessClosure, +} from "../apis/messClosures"; +import { getAllHostels } from "../apis/hostel"; // fetches list of all hostels for dropdown + +// ── Small reusable toast ────────────────────────────────────────────────────── +const Toast = ({ message, type, onClose }) => { + useEffect(() => { + const t = setTimeout(onClose, 4000); + return () => clearTimeout(t); + }, [onClose]); + + const colors = + type === "error" + ? "bg-red-50 border-red-200 text-red-700" + : "bg-green-50 border-green-200 text-green-700"; + + return ( +
+ {message} + +
+ ); +}; + +// ── Modal shell ─────────────────────────────────────────────────────────────── +const Modal = ({ title, onClose, children }) => ( +
+
+
+

{title}

+ +
+
{children}
+
+
+); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +/** Returns true if a closure can still be edited/deleted (within 8 hours of creation) */ +const isEditable = (createdAt) => { + if (!createdAt) return false; + const diffMs = Date.now() - new Date(createdAt).getTime(); + return diffMs < 8 * 60 * 60 * 1000; // 8 hours in ms +}; + +/** Returns minimum date string (today + 48 h) for the date input */ +const minClosureDate = () => { + const d = new Date(Date.now() + 48 * 60 * 60 * 1000); + return d.toISOString().split("T")[0]; +}; + +const formatDate = (iso) => { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("en-IN", { + day: "2-digit", + month: "short", + year: "numeric", + }); +}; + +// ── Main component ──────────────────────────────────────────────────────────── +const MessClosurePage = () => { + // ── Data state ── + const [closures, setClosures] = useState([]); + const [hostels, setHostels] = useState([]); + const [loading, setLoading] = useState(false); + + // ── Filter state ── + const [filterHostel, setFilterHostel] = useState(""); + const [filterMonth, setFilterMonth] = useState(""); + const [filterYear, setFilterYear] = useState(""); + const [filterUpcoming, setFilterUpcoming] = useState(false); + + // ── Toast state ── + const [toast, setToast] = useState(null); // { message, type } + + // ── Modal state ── + const [showCreateModal, setShowCreateModal] = useState(false); + const [editTarget, setEditTarget] = useState(null); // closure object being edited + const [deleteTarget, setDeleteTarget] = useState(null); // closure object to confirm delete + + // ── Form state (shared between create & edit) ── + const emptyForm = { hostelId: "", closureDate: ""}; + const [form, setForm] = useState(emptyForm); + const [submitting, setSubmitting] = useState(false); + + // ── Toast helper ── + const showToast = (message, type = "success") => setToast({ message, type }); + + // ── Fetch all closures ── + const fetchClosures = useCallback(async () => { + setLoading(true); + try { + const filters = { + ...(filterHostel && { hostelId: filterHostel }), + ...(filterMonth && { month: filterMonth }), + ...(filterYear && { year: filterYear }), + ...(filterUpcoming && { upcoming: true }), + }; + const data = await getAllMessClosures(filters); + setClosures(data.closures || data || []); + } catch (err) { + showToast(err?.response?.data?.message || "Failed to load closures.", "error"); + } finally { + setLoading(false); + } + }, [filterHostel, filterMonth, filterYear, filterUpcoming]); + +// const fetchClosures = useCallback(async () => { +// setLoading(true); +// // TEMP MOCK DATA - remove this and uncomment the real code when backend is ready +// setClosures([ +// { +// _id: "1", +// hostelId: { hostel_name: "Brahmaputra" }, +// closureDate: "2026-03-15T00:00:00.000Z", +// month: 3, +// year: 2026, +// createdAt: new Date().toISOString(), // recent = edit/delete buttons should be active +// }, +// { +// _id: "2", +// hostelId: { hostel_name: "Lohit" }, +// closureDate: "2026-02-10T00:00:00.000Z", +// month: 2, +// year: 2026, +// createdAt: "2026-02-01T00:00:00.000Z", // old = edit/delete should be locked +// }, +// ]); +// setLoading(false); +// }, [filterHostel, filterMonth, filterYear, filterUpcoming]); + + // ── Fetch hostel list for dropdown ── + + useEffect(() => { + fetchHostels(); + }, [fetchHostels]); + + useEffect(() => { + fetchClosures(); + }, [fetchClosures]); + + // ── Open create modal ── + const openCreate = () => { + setForm(emptyForm); + setShowCreateModal(true); + }; + + // ── Open edit modal ── + const openEdit = (closure) => { + if (!isEditable(closure.createdAt)) { + showToast( + "This closure cannot be edited — more than 8 hours have passed since it was created.", + "error" + ); + return; + } + setEditTarget(closure); + setForm({ + hostelId: closure.hostel?._id || closure.hostelId || "", + closureDate: closure.closureDate?.split("T")[0] || "", + reason: closure.reason || "", + }); + }; + + // ── Validate form ── + const validateForm = () => { + if (!form.hostelId) return "Please select a hostel."; + if (!form.closureDate) return "Please pick a closure date."; + const selectedDate = new Date(form.closureDate); + const minDate = new Date(Date.now() + 48 * 60 * 60 * 1000); + if (selectedDate < minDate) { + return "Closure must be scheduled at least 48 hours in advance."; + } + return null; +}; + // ── Submit create ── + const handleCreate = async (e) => { + + const err = validateForm(); + if (err) { showToast(err, "error"); return; } + setSubmitting(true); + try { + await createMessClosure({ + hostelId: form.hostelId, + closureDate: form.closureDate, + }); + showToast("Mess closure scheduled successfully."); + setShowCreateModal(false); + setForm(emptyForm); + fetchClosures(); + } catch (err) { + showToast(err?.response?.data?.message || "Failed to schedule closure.", "error"); + } finally { + setSubmitting(false); + } + }; + + // ── Submit edit ── + const handleEdit = async (e) => { + e.preventDefault(); + + const validErr = validateForm(); + if (validErr) { showToast(validErr, "error"); return; } + setSubmitting(true); + try { + await updateMessClosure(editTarget._id, { + closureDate: form.closureDate, + }); + showToast("Closure updated successfully."); + setEditTarget(null); + setForm(emptyForm); + fetchClosures(); + } catch (err) { + showToast(err?.response?.data?.message || "Failed to update closure.", "error"); + } finally { + setSubmitting(false); + } + }; + + // ── Confirm delete ── + const handleDelete = async () => { + + if (!deleteTarget) return; + if (!isEditable(deleteTarget.createdAt)) { + showToast( + "This closure cannot be deleted — more than 8 hours have passed since it was created.", + "error" + ); + setDeleteTarget(null); + return; + } + setSubmitting(true); + try { + await deleteMessClosure(deleteTarget._id); + showToast("Closure deleted."); + setDeleteTarget(null); + fetchClosures(); + } catch (err) { + showToast(err?.response?.data?.message || "Failed to delete closure.", "error"); + } finally { + setSubmitting(false); + } + }; + + // ── Shared form body (used in both create & edit modal) ── + const FormBody = ({ isEdit }) => ( +
+ {/* Hostel dropdown — only in create; in edit it's fixed */} + {!isEdit && ( +
+ + +
+ )} + + {/* Closure Date */} +
+ + setForm((f) => ({ ...f, closureDate: e.target.value }))} + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +

Must be at least 48 hours from now.

+
+ + {/* Reason +
+ +