From 5749d6e39765c12531a222fb4475d68b8145e1f6 Mon Sep 17 00:00:00 2001 From: Kushagra905 Date: Fri, 26 Jun 2026 13:06:11 +0530 Subject: [PATCH] feat: add Students list page with search, filter, and BR toggle (closes #144) --- admin/src/App.jsx | 9 + admin/src/apis/student.js | 77 +++ admin/src/components/Sidebar.jsx | 3 +- admin/src/pages/Students.jsx | 474 +++++++++++++++++++ server/index.js | 2 + server/modules/student/student.controller.js | 103 ++++ server/modules/student/student.routes.js | 20 + 7 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 admin/src/apis/student.js create mode 100644 admin/src/pages/Students.jsx create mode 100644 server/modules/student/student.controller.js create mode 100644 server/modules/student/student.routes.js diff --git a/admin/src/App.jsx b/admin/src/App.jsx index 36f9958..a200ec3 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import Sidebar from "./components/Sidebar"; import BranchRepresentatives from "./pages/BranchRepresentatives"; +import Students from "./pages/Students"; import Courses from "./pages/Courses"; import CourseLinking from "./pages/CourseLinking"; import PrivateRoute from "./router_utils/PrivateRoutes"; @@ -31,6 +32,14 @@ function App() { } /> + + + + } + /> { + try { + const url = `${API_BASE_URL}api/student/all${brOnly ? "?isBR=true" : ""}`; + const response = await fetch(url, { credentials: "include" }); + return await response.json(); + } catch (error) { + console.error("Error fetching students:", error); + throw error; + } +}; + +// Search students by name or roll number +// Pass brOnly=true to restrict search results to Branch Representatives +export const searchStudents = async (query, brOnly = false) => { + try { + const params = new URLSearchParams({ q: query }); + if (brOnly) params.set("isBR", "true"); + + const response = await fetch( + `${API_BASE_URL}api/student/search?${params.toString()}`, + { credentials: "include" } + ); + return await response.json(); + } catch (error) { + console.error("Error searching students:", error); + throw error; + } +}; + +export const refreshStudentCourses = async (id) => { + try { + const response = await fetch(`${API_BASE_URL}api/student/refresh/${id}`, { + method: "PUT", + credentials: "include", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || result.message || "Failed to refresh courses"); + return result; + } catch (error) { + console.error("Error refreshing student courses:", error); + throw error; + } +}; + +export const deleteStudent = async (id) => { + try { + const response = await fetch(`${API_BASE_URL}api/student/${id}`, { + method: "DELETE", + credentials: "include", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || result.message || "Failed to delete student"); + return result; + } catch (error) { + console.error("Error deleting student:", error); + throw error; + } +}; + +export const semesterReset = async () => { + try { + const response = await fetch(`${API_BASE_URL}api/student/semester-reset`, { + method: "POST", + credentials: "include", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || result.message || "Semester reset failed"); + return result; + } catch (error) { + console.error("Error during semester reset:", error); + throw error; + } +}; \ No newline at end of file diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 379fb29..352f03d 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -1,10 +1,11 @@ import React from "react"; import { Link, useLocation } from "react-router-dom"; -import { FaBook, FaUsers, FaLayerGroup, FaLink } from "react-icons/fa"; +import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate } from "react-icons/fa"; import { adminLogout } from "@/apis/auth"; const navItems = [ { label: "Branch Representatives", to: "/admin/", icon: FaUsers }, + { label: "Students", to: "/admin/students", icon: FaUserGraduate }, { label: "Courses", to: "/admin/courses", icon: FaBook }, { label: "Course Linking", to: "/admin/course-linking", icon: FaLink }, ]; diff --git a/admin/src/pages/Students.jsx b/admin/src/pages/Students.jsx new file mode 100644 index 0000000..816a9fa --- /dev/null +++ b/admin/src/pages/Students.jsx @@ -0,0 +1,474 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { + fetchStudents, + searchStudents, + refreshStudentCourses, + deleteStudent, + semesterReset, +} from "@/apis/student"; +import AddBRs from "../components/AddBRs"; +import { FaRedo, FaSearch, FaSync, FaTrash, FaChevronDown, FaChevronUp, FaPlus, FaUserGraduate, FaUsers } from "react-icons/fa"; +const ConfirmDialog = ({ message, onConfirm, onCancel, loading }) => ( +
+
e.stopPropagation()} + > +

Are you sure?

+

{message}

+
+ + +
+
+
+); + +export default function Students() { + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [expandedId, setExpandedId] = useState(null); + const [showBROnly, setShowBROnly] = useState(false); + const [showAddModal, setShowAddModal] = useState(false); + + const [rowLoadingId, setRowLoadingId] = useState(null); + const [rowConfirm, setRowConfirm] = useState(null); + + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetLoading, setResetLoading] = useState(false); + const [resetSuccess, setResetSuccess] = useState(null); + const [resetError, setResetError] = useState(null); + +const loadStudents = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetchStudents(showBROnly); + setStudents(response.students || []); + } catch (err) { + setError(err.message || "An error occurred while fetching students."); + } finally { + setLoading(false); + } + }, [showBROnly]); + + useEffect(() => { + loadStudents(); + }, [loadStudents]); + + useEffect(() => { + if (!searchQuery.trim()) { + loadStudents(); + return; + } + + setSearching(true); + const timer = setTimeout(async () => { + try { + setError(null); + const response = await searchStudents(searchQuery.trim(), showBROnly); + setStudents(response.students || []); + } catch (err) { + setError(err.message || "Search failed."); + } finally { + setSearching(false); + } + }, 500); + + return () => clearTimeout(timer); + }, [searchQuery, showBROnly, loadStudents]); + + const handleToggleExpand = (id) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + const handleRowRefresh = async (id) => { + setRowLoadingId(id); + try { + await refreshStudentCourses(id); + loadStudents(); + } catch (err) { + setError(err.message || "Failed to refresh courses."); + } finally { + setRowLoadingId(null); + setRowConfirm(null); + } + }; + + const handleRowDelete = async (id) => { + setRowLoadingId(id); + try { + await deleteStudent(id); + loadStudents(); + } catch (err) { + setError(err.message || "Failed to delete student."); + } finally { + setRowLoadingId(null); + setRowConfirm(null); + } + }; + + const handleSemesterReset = async () => { + setResetLoading(true); + setResetError(null); + setResetSuccess(null); + try { + const result = await semesterReset(); + setResetSuccess(result.message || "Semester reset complete."); + setShowResetConfirm(false); + loadStudents(); + } catch (err) { + setResetError(err.message || "Semester reset failed."); + setShowResetConfirm(false); + } finally { + setResetLoading(false); + } + }; + + const isLoading = loading || searching; + + return ( +
+
+
+
+

Students

+

+ View and manage all registered students +

+
+
+ {showBROnly && ( + + )} + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full sm:w-72 pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-xl bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all placeholder:text-gray-400" + /> + {searching && ( +
+ )} +
+ +
+ + +
+
+
+ {resetSuccess && ( +
+ {resetSuccess} + +
+ )} + {resetError && ( +
+ {resetError} + +
+ )} + +
+
+

+ {showBROnly ? "Branch Representatives" : "Student Table"} +

+ {!isLoading && ( + + {students.length} result{students.length !== 1 ? "s" : ""} + + )} +
+ + {isLoading && ( +
+
+ {searching ? "Searching..." : "Loading..."} +
+ )} + + {!isLoading && error && ( +

+ {error} +

+ )} + + {!isLoading && !error && students.length === 0 && ( +
+ {showBROnly ? "No branch representatives found." : "No students found."} +
+ )} + + {!isLoading && !error && students.length > 0 && ( +
+ + + + + + + + + + + + + + {students.map((student) => { + const isExpanded = expandedId === student._id; + const isRowLoading = rowLoadingId === student._id; + + return ( + + + + + + + + + + + + {expandedId === student._id && ( + + + + )} + + ); + })} + +
+ Roll Number + + Name + + Email + + Degree + + Semester + + Actions +
+ {student.rollNumber} + {student.name}{student.email}{student.degree}{student.semester} +
+ + +
+
+ +
+
+
+

+ Department +

+

{student.department}

+
+
+

+ BR Status +

+ + {student.isBR ? "Branch Rep" : "Regular Student"} + +
+
+

+ Courses Enrolled +

+

+ {student.courses?.length || 0} +

+
+
+

+ Student ID +

+

+ {student._id} +

+
+
+ + {student.courses?.length > 0 && ( +
+

+ Current Courses +

+
+ {student.courses.map((course, idx) => ( + + {course.code} + + ))} +
+
+ )} +
+
+ )} +
+ + {rowConfirm && ( + + rowConfirm.type === "delete" + ? handleRowDelete(rowConfirm.id) + : handleRowRefresh(rowConfirm.id) + } + onCancel={() => setRowConfirm(null)} + /> + )} +{showResetConfirm && ( + setShowResetConfirm(false)} + /> + )} + + {showAddModal && ( + setShowAddModal(false)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/server/index.js b/server/index.js index 003026c..ee4b314 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import eventRoutes from "./modules/event/event.routes.js"; import contributionRoutes from "./modules/contribution/contribution.routes.js"; import adminRoutes from "./modules/admin/admin.routes.js"; import brRoutes from "./modules/br/br.routes.js"; +import studentRoutes from "./modules/student/student.routes.js"; import fileRoutes from "./modules/file/file.routes.js"; import folderRoutes from "./modules/folder/folder.routes.js"; import yearRoutes from "./modules/year/year.routes.js"; @@ -58,6 +59,7 @@ app.use("/api/event", eventRoutes); app.use("/api/contribution", contributionRoutes); app.use("/api/admin", adminRoutes); app.use("/api/br", brRoutes); +app.use("/api/student", studentRoutes); app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); diff --git a/server/modules/student/student.controller.js b/server/modules/student/student.controller.js new file mode 100644 index 0000000..e689922 --- /dev/null +++ b/server/modules/student/student.controller.js @@ -0,0 +1,103 @@ +import User from "../user/user.model.js"; +import { fetchCoursesForBr } from "../auth/auth.controller.js"; +import logger from "../../utils/logger.js"; + +// GET /api/student/all?isBR=true +// Returns every student, sorted by rollNumber descending. +// If isBR=true is passed, only returns students who are Branch Representatives. +const getAllStudents = async (req, res) => { + try { + const { isBR } = req.query; + const filter = isBR === "true" ? { isBR: true } : {}; + + const students = await User.find(filter).sort({ rollNumber: -1 }); + res.status(200).json({ students }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// GET /api/student/search?q=...&isBR=true +// Searches students by name or rollNumber using a MongoDB $or regex query. +// If isBR=true is passed, results are additionally restricted to Branch Representatives. +// Results are still sorted by rollNumber descending. +const searchStudents = async (req, res) => { + try { + const { q = "", isBR } = req.query; + const brFilter = isBR === "true" ? { isBR: true } : {}; + + if (!q.trim()) { + const students = await User.find(brFilter).sort({ rollNumber: -1 }); + return res.status(200).json({ students }); + } + + const searchRegex = new RegExp(q, "i"); + const orConditions = [{ name: { $regex: searchRegex } }]; + + // rollNumber is stored as a Number, so only add a numeric match + // if the query looks like a number (partial roll number searches + // like "2210" are matched via regex against the stringified field + // using $expr, since Mongo can't $regex a Number field directly). + orConditions.push({ + $expr: { + $regexMatch: { + input: { $toString: "$rollNumber" }, + regex: q, + options: "i", + }, + }, + }); + + const students = await User.find({ ...brFilter, $or: orConditions }).sort({ rollNumber: -1 }); + res.status(200).json({ students }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// PUT /api/student/refresh/:id +const refreshStudentCourses = async (req, res) => { + try { + const { id } = req.params; + const student = await User.findById(id); + if (!student) return res.status(404).json({ error: "Student not found" }); + + await fetchCoursesForBr(student.rollNumber); + res.status(200).json({ message: "Courses refreshed successfully" }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// DELETE /api/student/:id +const deleteStudent = async (req, res) => { + try { + const { id } = req.params; + const student = await User.findByIdAndDelete(id); + if (!student) return res.status(404).json({ error: "Student not found" }); + + res.status(200).json({ message: "Student deleted successfully" }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// POST /api/student/semester-reset +const semesterReset = async (req, res) => { + try { + const result = await User.updateMany({}, { $set: { courses: [] } }); + res.status(200).json({ + message: "Semester reset successful. All student courses cleared.", + modifiedCount: result.modifiedCount, + }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +export { getAllStudents, searchStudents, refreshStudentCourses, deleteStudent, semesterReset }; \ No newline at end of file diff --git a/server/modules/student/student.routes.js b/server/modules/student/student.routes.js new file mode 100644 index 0000000..eec2e53 --- /dev/null +++ b/server/modules/student/student.routes.js @@ -0,0 +1,20 @@ +import express from "express"; +import catchAsync from "../../utils/catchAsync.js"; +import isAdmin from "../../middleware/isAdmin.js"; +import { + getAllStudents, + searchStudents, + refreshStudentCourses, + deleteStudent, + semesterReset, +} from "./student.controller.js"; + +const router = express.Router(); + +router.get("/all", isAdmin, catchAsync(getAllStudents)); +router.get("/search", isAdmin, catchAsync(searchStudents)); +router.put("/refresh/:id", isAdmin, catchAsync(refreshStudentCourses)); +router.post("/semester-reset", isAdmin, catchAsync(semesterReset)); +router.delete("/:id", isAdmin, catchAsync(deleteStudent)); + +export default router; \ No newline at end of file