diff --git a/admin/src/App.jsx b/admin/src/App.jsx index 3c594a38..5ecfcff8 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 CoursesWithoutBR from "./pages/CoursesWithoutBR"; import Students from "./pages/Students"; import Courses from "./pages/Courses"; import CourseLinking from "./pages/CourseLinking"; @@ -48,6 +49,14 @@ function App() { } /> + + + + } + /> { try { const response = await fetch(`${API_BASE_URL}api/br/allBRs`, { credentials: "include", - headers: { - Authorization: "Bearer admin-coursehub-cc23-golang", - }, }); return await response.json(); } catch (error) { @@ -16,6 +13,19 @@ export const fetchBRs = async () => { } }; +// Fetch all courses that don't have a branch representative +export const fetchCoursesWithoutBR = async () => { + try { + const response = await fetch(`${API_BASE_URL}api/br/coursesWithoutBR`, { + credentials: "include", + }); + return await response.json(); + } catch (error) { + console.error("Error fetching courses without BR:", error); + throw error; + } +}; + // Create a single BR export const createBR = async (email) => { try { @@ -24,7 +34,6 @@ export const createBR = async (email) => { credentials: "include", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ email }), }); @@ -69,7 +78,6 @@ export const uploadBRs = async (file) => { credentials: "include", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ emails }), }); diff --git a/admin/src/components/CoursesWithoutBRTable.jsx b/admin/src/components/CoursesWithoutBRTable.jsx new file mode 100644 index 00000000..3ad15adb --- /dev/null +++ b/admin/src/components/CoursesWithoutBRTable.jsx @@ -0,0 +1,41 @@ +import React from "react"; + +const CoursesWithoutBRTable = ({ courses }) => { + return ( +
+
+

Courses Without BR

+
+ {courses.length === 0 ? ( +
No courses found.
+ ) : ( +
+ + + + + + + + + {courses.map((course) => ( + + + + + ))} + +
+ Code + + Name +
+ {course.code} + {course.name}
+
+ )} +
+ ); +}; + +export default CoursesWithoutBRTable; diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 352f03da..47e4b673 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -1,6 +1,6 @@ import React from "react"; import { Link, useLocation } from "react-router-dom"; -import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate } from "react-icons/fa"; +import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTriangle } from "react-icons/fa"; import { adminLogout } from "@/apis/auth"; const navItems = [ @@ -8,6 +8,7 @@ const navItems = [ { label: "Students", to: "/admin/students", icon: FaUserGraduate }, { label: "Courses", to: "/admin/courses", icon: FaBook }, { label: "Course Linking", to: "/admin/course-linking", icon: FaLink }, + { label: "Courses Without BR", to: "/admin/courses-without-br", icon: FaExclamationTriangle }, ]; const Sidebar = () => { diff --git a/admin/src/pages/CoursesWithoutBR.jsx b/admin/src/pages/CoursesWithoutBR.jsx new file mode 100644 index 00000000..2cfd8b79 --- /dev/null +++ b/admin/src/pages/CoursesWithoutBR.jsx @@ -0,0 +1,44 @@ +import React, { useState, useEffect } from "react"; +import CoursesWithoutBRTable from "../components/CoursesWithoutBRTable"; +import { fetchCoursesWithoutBR } from "@/apis/br"; + +export default function CoursesWithoutBR() { + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadCourses = async () => { + try { + setLoading(true); + const response = await fetchCoursesWithoutBR(); + setCourses(response.coursesWithoutBR); + setLoading(false); + } catch (err) { + setError(err.message || "An error occurred while fetching courses without BR."); + setLoading(false); + } + }; + + useEffect(() => { + loadCourses(); + }, []); + + return ( +
+
+
+

Courses Without BR

+

+ Courses that don't have a branch representative assigned yet +

+
+
+ +
+ {loading &&

Loading...

} + {error &&

{error}

} + {!loading && !error && } +
+
+ ); +} diff --git a/server/modules/br/br.controller.js b/server/modules/br/br.controller.js index bd9a9588..d4bcbc5a 100644 --- a/server/modules/br/br.controller.js +++ b/server/modules/br/br.controller.js @@ -2,9 +2,19 @@ import BR from "./br.model.js"; import User from "../user/user.model.js"; import { fetchCoursesForBr } from "../auth/auth.controller.js"; import logger from "../../utils/logger.js"; +import CourseModel from "../course/course.model.js"; const normalizeEmail = (email) => email?.toString().trim().toLowerCase(); +// Mirrors normalizeCourseCode (utils/course.js) as an aggregation expression: uppercase + strip spaces. +const normalizedCodeExpr = (field) => ({ + $replaceAll: { + input: { $toUpper: { $ifNull: [field, ""] } }, + find: " ", + replacement: "", + }, +}); + const findUserByEmailInsensitive = async (email) => { if (!email) return null; return User.findOne({ email }).collation({ locale: "en", strength: 2 }); @@ -142,5 +152,45 @@ const getBRs = async (req, res) => { res.status(500).json({ error: "Internal Server Error" }); } }; - -export { updateBRs, createBR, getAll, deleteBR, getBRs }; +const getCoursesWithoutBR = async (req, res) => { + try { + // Normalized course codes covered by any registered BR, computed in the DB via a $lookup join. + const coveredCodesResult = await BR.aggregate([ + { $addFields: { emailLower: { $toLower: "$email" } } }, + { + $lookup: { + from: User.collection.name, + let: { emailLower: "$emailLower" }, + pipeline: [ + { $match: { $expr: { $eq: [{ $toLower: "$email" }, "$$emailLower"] } } }, + { $project: { courses: 1, _id: 0 } }, + ], + as: "brUser", + }, + }, + { $unwind: "$brUser" }, + { $unwind: { path: "$brUser.courses", preserveNullAndEmptyArrays: false } }, + { + $group: { + _id: null, + codes: { $addToSet: normalizedCodeExpr("$brUser.courses.code") }, + }, + }, + ]); + + const coveredCodes = coveredCodesResult[0]?.codes || []; + + // Courses whose normalized code isn't in the covered set, filtered at the DB level. + const coursesWithoutBR = await CourseModel.aggregate([ + { $addFields: { normalizedCode: normalizedCodeExpr("$code") } }, + { $match: { normalizedCode: { $nin: coveredCodes } } }, + { $project: { normalizedCode: 0 } }, + ]); + + res.status(200).json({ coursesWithoutBR }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; +export { updateBRs, createBR, getAll, deleteBR, getBRs, getCoursesWithoutBR }; diff --git a/server/modules/br/br.routes.js b/server/modules/br/br.routes.js index 803d21b5..55ed1923 100644 --- a/server/modules/br/br.routes.js +++ b/server/modules/br/br.routes.js @@ -1,5 +1,5 @@ import express from "express"; -import { updateBRs, createBR, getAll, deleteBR, getBRs } from "./br.controller.js"; +import { updateBRs, createBR, getAll, deleteBR, getBRs, getCoursesWithoutBR } from "./br.controller.js"; const router = express.Router(); @@ -7,6 +7,7 @@ router.post("/updateList", updateBRs); router.post("/create", createBR); router.get("/all", getAll); router.get("/allBRs", getBRs); +router.get("/coursesWithoutBR", getCoursesWithoutBR); router.delete("/delete", deleteBR); export default router;