diff --git a/src/App.jsx b/src/App.jsx index 04fe1b6cd..79342dd8d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { Notifications } from "@mantine/notifications"; import { useSelector } from "react-redux"; import { Layout } from "./app/AppLayout"; +import { PluggedModule } from "./ui/routing/PluggedModule"; import DashboardHome from "./Modules/Dashboard/DashboardHome"; import NotificationsPage from "./Modules/Dashboard/dashboardNotifications"; import Profile from "./Modules/Dashboard/StudentProfile/profilePage"; @@ -187,6 +188,12 @@ export default function App() { } /> } /> } /> + {/* Served by Fusion-Integrated, shown inside this shell. */} + } + /> + } /> } diff --git a/src/Modules/Academic/FeeStructure.jsx b/src/Modules/Academic/FeeStructure.jsx new file mode 100644 index 000000000..f440fe1f0 --- /dev/null +++ b/src/Modules/Academic/FeeStructure.jsx @@ -0,0 +1,925 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import axios from "axios"; +import PropTypes from "prop-types"; +import { + ActionIcon, + Alert, + Button, + Group, + Loader, + Modal, + NumberInput, + Paper, + Select, + Stack, + Switch, + Table, + Text, + TextInput, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { Copy, Info, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; + +import { + feeStructureDetailRoute, + feeStructureReplicateRoute, + feeStructureTemplateRoute, + feeStructuresRoute, +} from "../../routes/academicRoutes"; + +const authConfig = () => ({ + headers: { Authorization: `Token ${localStorage.getItem("authToken")}` }, +}); + +const message = (error, fallback) => + error?.response?.data?.error || error?.response?.data?.detail || fallback; + +const SECTIONS = [ + { + key: "one_time_heads", + title: "A. One-time payment at the time of admission", + total: "Total One Time Fees (A) in Rs.", + }, + { + key: "semester_heads", + title: "B. Semester Fees (Academic fee) – Per semester", + total: "Total Semester Fees (Academic fee) (B) in Rs.", + }, + { + key: "tuition_hostel_heads", + title: "C. Semester Fees (Tuition fee and hostel fee) – Per semester", + total: "Total Semester Fees (Tuition fee & hostel fee) (C) in Rs.", + perSemester: true, + }, +]; + +const CATEGORIES = [ + { value: "UG", label: "UG", tab: "UG: Undergraduate" }, + { value: "PG", label: "PG", tab: "PG: Post Graduate" }, + { value: "PHD", label: "Ph.D.", tab: "PhD: Doctor of Philosophy" }, +]; + +const academicYear = (year) => + year ? `${year}-${String(Number(year) + 1).slice(-2)}` : ""; + +// "NIL" is what the circular prints where a category pays nothing. +const amountOf = (value, semester) => { + const raw = Array.isArray(value) + ? value[Math.min(semester - 1, value.length - 1)] + : value; + const text = String(raw ?? "").trim(); + if (!text || ["NIL", "-"].includes(text.toUpperCase())) return 0; + const parsed = Number(text.replace(/,/g, "")); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const sumSection = (rows, column, semester) => + (rows ?? []).reduce( + (total, row) => total + amountOf(row[column], semester), + 0, + ); + +const indian = (value) => { + const [whole, fraction] = Number(value || 0) + .toFixed(2) + .split("."); + const head = whole.slice(0, -3); + const tail = whole.slice(-3); + const grouped = head + ? `${head.replace(/\B(?=(\d{2})+(?!\d))/g, ",")},${tail}` + : tail; + return `${grouped}.${fraction}`; +}; + +export default function FeeStructure() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [replicating, setReplicating] = useState(null); + const [category, setCategory] = useState("UG"); + const [preview, setPreview] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const { data } = await axios.get(feeStructuresRoute, authConfig()); + setRows(data.results ?? []); + } catch (error) { + notifications.show({ + color: "red", + title: "Could not load fee structures", + message: message(error, "Please try again."), + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const startNew = async (forCategory) => { + try { + const { data } = await axios.get(feeStructureTemplateRoute, { + ...authConfig(), + params: { programme_category: forCategory }, + }); + setDraft({ ...data, start_year: new Date().getFullYear() }); + } catch (error) { + notifications.show({ + color: "red", + title: "Could not start a new structure", + message: message(error, "Please try again."), + }); + } + }; + + const save = async () => { + setSaving(true); + try { + if (draft.id) { + await axios.put(feeStructureDetailRoute(draft.id), draft, authConfig()); + } else { + await axios.post(feeStructuresRoute, draft, authConfig()); + } + notifications.show({ color: "green", message: "Fee structure saved." }); + setDraft(null); + load(); + } catch (error) { + notifications.show({ + color: "red", + title: "Not saved", + message: message(error, "Please check the figures and try again."), + }); + } finally { + setSaving(false); + } + }; + + const remove = async (row) => { + try { + await axios.delete(feeStructureDetailRoute(row.id), authConfig()); + load(); + } catch (error) { + notifications.show({ + color: "red", + title: "Not removed", + message: message(error, "Please try again."), + }); + } + }; + + const replicate = async () => { + try { + await axios.post( + feeStructureReplicateRoute(replicating.row.id), + { start_year: replicating.year }, + authConfig(), + ); + notifications.show({ + color: "green", + message: `Copied into ${academicYear(replicating.year)}.`, + }); + setReplicating(null); + load(); + } catch (error) { + notifications.show({ + color: "red", + title: "Not copied", + message: message(error, "Please try again."), + }); + } + }; + + const visible = useMemo( + () => rows.filter((row) => row.programme_category === category), + [rows, category], + ); + + return ( + + + + {CATEGORIES.map((entry) => ( + + ))} + + + + + + + + + Name + Academic Year + Actions + Edit + + + + {loading && ( + + + + + + + + )} + {!loading && !visible.length && ( + + + + No fee structure for {category} yet. + + + + )} + {visible.map((row) => ( + setPreview(row)} + > + {row.name} + {row.academic_year} + event.stopPropagation()} + > + + + event.stopPropagation()}> + + setDraft({ ...row })} + > + + + remove(row)} + > + + + + + + ))} + +
+
+ + setReplicating(null)} + title="Replicate into a new session" + centered + > + + + setReplicating((current) => ({ ...current, year: value })) + } + min={2000} + max={2100} + /> + + Every head is copied, so only what changed needs editing. + + + + + + {preview && ( + setPreview(null)} + /> + )} + + {draft && ( + setDraft(null)} + /> + )} +
+ ); +} + +function FeeStructurePreview({ structure, onClose }) { + const columns = [ + { key: "general", label: structure.general_label }, + { key: "concession", label: structure.concession_label }, + ]; + + return ( + + + {SECTIONS.map((section) => ( + + + {section.title} + + + + + S.No. + Head + {columns.map((column) => ( + + {column.label} + + ))} + + + + {(structure[section.key] ?? []).map((row, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {index + 1} + {row.head} + {columns.map((column) => ( + + {Array.isArray(row[column.key]) + ? row[column.key].join(" / ") + : row[column.key] || "—"} + + ))} + + ))} + + + + {section.total} + + {columns.map((column) => ( + + + {indian( + sumSection(structure[section.key], column.key, 1), + )} + + + ))} + + +
+
+ ))} + + + + Grand Total (A+B+C) in Rs. + + + + + Category + {(structure.grand_total ?? []).map((entry) => ( + + Sem-{entry.semester} + + ))} + + + + {columns.map((column) => ( + + {column.label} + {(structure.grand_total ?? []).map((entry) => ( + + {indian(entry[column.key])} + + ))} + + ))} + +
+
+ + + + D. Semester Fees (Mess) – Per semester + + + Mess Advance: {indian(structure.mess_advance_per_semester)} + + + + {Boolean(structure.notes?.length) && ( + + {structure.notes.map((note) => ( + + • {note} + + ))} + + )} + + + Read only. Use the edit icon on the row to change these figures. + +
+
+ ); +} + +function FeeStructureEditor({ draft, setDraft, onSave, saving, onClose }) { + const semesters = Number(draft.semester_count) || 1; + const columns = [ + { key: "general", label: draft.general_label || "Gen/OBC/EWS" }, + { key: "concession", label: draft.concession_label || "PWD/SC/ST" }, + ]; + + const set = (patch) => setDraft((current) => ({ ...current, ...patch })); + + const setHead = (section, index, patch) => + setDraft((current) => ({ + ...current, + [section]: current[section].map((row, position) => + position === index ? { ...row, ...patch } : row, + ), + })); + + const addHead = (section) => + setDraft((current) => ({ + ...current, + [section]: [ + ...current[section], + { head: "", general: "", concession: "" }, + ], + })); + + const removeHead = (section, index) => + setDraft((current) => ({ + ...current, + [section]: current[section].filter((_, position) => position !== index), + })); + + const grandTotal = useMemo( + () => + Array.from({ length: semesters }, (_, offset) => { + const semester = offset + 1; + const of = (column) => + sumSection(draft.semester_heads, column, semester) + + sumSection(draft.tuition_hostel_heads, column, semester) + + (semester === 1 ? sumSection(draft.one_time_heads, column, 1) : 0); + return { + semester, + general: of("general"), + concession: of("concession"), + }; + }), + [draft, semesters], + ); + + return ( + + + + { + setPurpose(value); + setCustomPurpose(""); + setPreview(null); + }} + searchable + disabled={!selectedStudent} + /> + {purpose === "Other" && ( + { + setCustomPurpose(event.currentTarget.value); + setPreview(null); + }} + maxLength={150} + required + /> + )} + + {rows.length > 0 && ( + + + Paid / Unpaid + + {rows.map((row) => ( + + + {row.label} + + setStatus(row.semester, value)} + /> + + ))} + {unmarked.length > 0 && ( + } p="xs"> + Mark {unmarked.map((row) => row.label).join(", ")} before + generating. + + )} + + )} + + + + + + + + + + + + {preview ? ( +
+
+
+
+
{preview.meta.signatory_name}
+
{preview.meta.signatory_title}
+
+
+
{preview.meta.reference_preview}
+
Date: {preview.meta.issued_on}
+
+
+

+ TO WHOM SO EVER IT MAY CONCERN +

+

+ This is to certify that{" "} + + {preview.student.salutation} {preview.student.name} + {" "} + (Roll No. {preview.student.roll_number}){" "} + {preview.student.relation}{" "} + MR. {preview.student.father_name} is a + student of{" "} + + {ordinalWithSuperscript(preview.student.year_ordinal)} Year + {" "} + ({ordinalWithSuperscript(preview.student.semester_ordinal)}{" "} + Semester) {preview.student.programme} in{" "} + {preview.student.discipline} ({" "} + {preview.student.duration_text} course duration:{" "} + {preview.student.start_year} to{" "} + {preview.student.end_year}) at{" "} + {preview.meta.institute_name}. The fee details of{" "} + {preview.student.programme_short} Programme + for the purpose of applying for{" "} + {preview.purpose} are given below: +

+
    +
  • + Semester Wise Fee Details: +
  • +
+ + + {preview.rows.map((row) => ( + + + + + + + ))} + + + + + +
{row.semester} + {ordinalWithSuperscript( + row.label.replace(" Semester", ""), + )}{" "} + Semester + {row.amount_display}{row.status}
+ Total + {indianCurrency(totalOf(preview.rows))} +
+
    + {(preview.fee?.notes ?? []).map((note) => ( +
  • {note}
  • + ))} +
+
+ ({preview.meta.signatory_name}) +
+
+
+ ) : ( + } + title="Certificate preview" + > + Enter a roll number, fetch the student, select a purpose, mark + each semester Paid or Unpaid, and choose Preview. The certificate + can then be printed or downloaded as PDF. + + )} +
+ + setHistoryOpened(false)} + variant="fee" + /> + + ); +} diff --git a/src/Modules/Certificates/GeneratedCertificatesModal.jsx b/src/Modules/Certificates/GeneratedCertificatesModal.jsx index 3c0309514..62559fcce 100644 --- a/src/Modules/Certificates/GeneratedCertificatesModal.jsx +++ b/src/Modules/Certificates/GeneratedCertificatesModal.jsx @@ -22,6 +22,8 @@ import PropTypes from "prop-types"; import { bonafideCertificatePdfRoute, bonafideCertificatesRoute, + feeCertificateHistoryPdfRoute, + feeCertificatesRoute, } from "../../routes/academicRoutes"; import classes from "./GeneratedCertificatesModal.module.css"; @@ -53,7 +55,12 @@ const filenameFrom = (header, fallback) => { return match?.[1] || fallback; }; -export default function GeneratedCertificatesModal({ opened, onClose }) { +export default function GeneratedCertificatesModal({ opened, onClose, variant }) { + const isFee = variant === "fee"; + const listRoute = isFee ? feeCertificatesRoute : bonafideCertificatesRoute; + const pdfRoute = isFee + ? feeCertificateHistoryPdfRoute + : bonafideCertificatePdfRoute; const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search.trim(), 300); const [page, setPage] = useState(1); @@ -73,7 +80,7 @@ export default function GeneratedCertificatesModal({ opened, onClose }) { const controller = new AbortController(); setLoading(true); axios - .get(bonafideCertificatesRoute, { + .get(listRoute, { ...authConfig(), params: { page, @@ -116,7 +123,7 @@ export default function GeneratedCertificatesModal({ opened, onClose }) { setPreviewingId(certificate.id); try { const response = await axios.get( - bonafideCertificatePdfRoute(certificate.id), + pdfRoute(certificate.id), { ...authConfig(), responseType: "blob" }, ); setPdfPreview({ @@ -138,7 +145,7 @@ export default function GeneratedCertificatesModal({ opened, onClose }) { setDownloadingId(certificate.id); try { const response = await axios.get( - bonafideCertificatePdfRoute(certificate.id), + pdfRoute(certificate.id), { ...authConfig(), params: { download: 1 }, @@ -304,4 +311,9 @@ export default function GeneratedCertificatesModal({ opened, onClose }) { GeneratedCertificatesModal.propTypes = { opened: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, + variant: PropTypes.oneOf(["bonafide", "fee"]), +}; + +GeneratedCertificatesModal.defaultProps = { + variant: "bonafide", }; diff --git a/src/Modules/Certificates/index.jsx b/src/Modules/Certificates/index.jsx index 06f05c148..28530141f 100644 --- a/src/Modules/Certificates/index.jsx +++ b/src/Modules/Certificates/index.jsx @@ -7,6 +7,7 @@ import { CERTIFICATES_BASE, CERTIFICATE_PAGES } from "./pages"; const COMPONENTS = { bonafideCertificate: lazy(() => import("./BonafideCertificate")), + feeCertificate: lazy(() => import("./FeeCertificate")), }; export default function Certificates() { diff --git a/src/Modules/Certificates/pages.js b/src/Modules/Certificates/pages.js index 4befd5c92..2399127db 100644 --- a/src/Modules/Certificates/pages.js +++ b/src/Modules/Certificates/pages.js @@ -6,7 +6,16 @@ export const CERTIFICATE_PAGES = [ slug: "bonafide-certificate", title: "Bonafide Certificate", icon: "Certificate", - group: null, + group: "Certificates", + roles: ["acadadmin"], + desktopOnly: true, + }, + { + key: "feeCertificate", + slug: "fee-certificate", + title: "Paid / Unpaid Certificate", + icon: "Receipt", + group: "Certificates", roles: ["acadadmin"], desktopOnly: true, }, diff --git a/src/app/AppLayout.jsx b/src/app/AppLayout.jsx index c758810b6..91ddeadd0 100644 --- a/src/app/AppLayout.jsx +++ b/src/app/AppLayout.jsx @@ -46,6 +46,9 @@ export function Layout({ children = null }) { const accessibleModules = useSelector( (state) => state.user.currentAccessibleModules, ); + const pluggedNavigation = useSelector( + (state) => state.user.pluggedNavigation, + ); const unreadCount = useSelector((state) => state.notification.unreadCount); const profilePhoto = useSelector((state) => state.user.profilePhoto); @@ -90,8 +93,14 @@ export function Layout({ children = null }) { }, [dispatch, role, profilePhoto]); const navGroups = useMemo( - () => buildNavGroups({ role, accessibleModules, programmeType }), - [role, accessibleModules, programmeType], + () => + buildNavGroups({ + role, + accessibleModules, + programmeType, + pluggedNavigation, + }), + [role, accessibleModules, programmeType, pluggedNavigation], ); const bottomNavItems = useMemo( diff --git a/src/helper/validateauth.jsx b/src/helper/validateauth.jsx index fdac35064..34f01652e 100644 --- a/src/helper/validateauth.jsx +++ b/src/helper/validateauth.jsx @@ -9,6 +9,7 @@ import { setRoles, setRole, setAccessibleModules, + setPluggedNavigation, setCurrentAccessibleModules, setMustCompleteProfile, setAuthChecked, @@ -48,6 +49,7 @@ function ValidateAuth() { last_selected_role, roll_no, must_complete_profile = false, + plugged_navigation = [], } = data; dispatch(setUserName(name)); @@ -58,6 +60,7 @@ function ValidateAuth() { if (selectedRole) dispatch(setRole(selectedRole)); dispatch(setAccessibleModules(accessible_modules)); + dispatch(setPluggedNavigation(plugged_navigation)); dispatch(setCurrentAccessibleModules()); dispatch(setMustCompleteProfile(must_complete_profile)); dispatch(setAuthChecked(true)); diff --git a/src/redux/userslice.jsx b/src/redux/userslice.jsx index af3743a39..d8f8259a4 100644 --- a/src/redux/userslice.jsx +++ b/src/redux/userslice.jsx @@ -15,6 +15,7 @@ const userSlice = createSlice({ authChecked: false, // /api/auth/me has resolved (gates route rendering) programmeType: null, // UG | PG | PHD — decides which academic pages exist profilePhoto: "", // student's own photo, shown on the sidebar avatar + pluggedNavigation: [], // menus contributed by modules another service serves }, reducers: { setProgrammeType: (state, action) => { @@ -41,6 +42,9 @@ const userSlice = createSlice({ setRole: (state, action) => { state.role = action.payload; }, + setPluggedNavigation: (state, action) => { + state.pluggedNavigation = action.payload ?? []; + }, setAccessibleModules: (state, action) => { state.accessibleModules = action.payload; }, @@ -63,6 +67,7 @@ export const { setRoles, setRole, setAccessibleModules, + setPluggedNavigation, setCurrentAccessibleModules, setMustCompleteProfile, setAuthChecked, diff --git a/src/routes/academicRoutes/index.jsx b/src/routes/academicRoutes/index.jsx index 330312526..34915148d 100644 --- a/src/routes/academicRoutes/index.jsx +++ b/src/routes/academicRoutes/index.jsx @@ -39,6 +39,19 @@ export const bonafidePdfRoute = `${host}/academic-procedures/api/acad/bonafide/p export const bonafideCertificatesRoute = `${host}/academic-procedures/api/acad/bonafide/certificates/`; export const bonafideCertificatePdfRoute = (certificateId) => `${bonafideCertificatesRoute}${certificateId}/pdf/`; + +export const feeCertificateStudentRoute = `${host}/academic-procedures/api/acad/fee-certificate/student/`; +export const feeCertificatePdfRoute = `${host}/academic-procedures/api/acad/fee-certificate/pdf/`; +export const feeCertificatesRoute = `${host}/academic-procedures/api/acad/fee-certificate/certificates/`; +export const feeCertificateHistoryPdfRoute = (certificateId) => + `${feeCertificatesRoute}${certificateId}/pdf/`; + +export const feeStructuresRoute = `${host}/academic-procedures/api/acad/fee-structures/`; +export const feeStructureTemplateRoute = `${feeStructuresRoute}template/`; +export const feeStructureDetailRoute = (structureId) => + `${feeStructuresRoute}${structureId}/`; +export const feeStructureReplicateRoute = (structureId) => + `${feeStructuresRoute}${structureId}/replicate/`; export const checkAllocationRoute = `${host}/aims/api/check-allocation`; export const startAllocationRoute = `${host}/aims/api/start-allocation`; export const addCourseToSlotsRoute = `${host}/aims/api/add-course-to-slots`; diff --git a/src/ui/nav/navigation.js b/src/ui/nav/navigation.js index dd809bb78..393b2cb4e 100644 --- a/src/ui/nav/navigation.js +++ b/src/ui/nav/navigation.js @@ -36,6 +36,8 @@ const GROUP_ICONS = { "Research Output": "Newspaper", Activities: "Megaphone", Personal: "IdentificationBadge", + Certificates: "Certificate", + "Fee Structure": "Money", }; const MODULE_SECTIONS = [ @@ -96,6 +98,24 @@ const OTHER_MODULES = [ }, ]; +// Modules another service serves, gated and routed exactly like ours. +const INTEGRATED_MODULES = [ + { + id: "placement_cell", + label: "Placement Cell", + icon: "Briefcase", + to: "/placement", + section: "Placement", + }, + { + id: "leave", + label: "Leave", + icon: "CalendarBlank", + to: "/leave", + section: "Leave", + }, +]; + const toLink = (base, page) => ({ code: page.key, label: page.title, @@ -147,6 +167,7 @@ export function buildNavGroups({ role, accessibleModules = {}, programmeType = null, + pluggedNavigation = [], } = {}) { const flags = { programmeType, role }; const groups = [ @@ -191,6 +212,27 @@ export function buildNavGroups({ ); if (others.length) groups.push({ section: "Modules", items: others }); + INTEGRATED_MODULES.filter((m) => accessibleModules[m.id]).forEach((m) => { + // The payload is sections, and the module code sits on the entries inside. + const entry = pluggedNavigation + .flatMap((g) => g.items ?? []) + .find((i) => i.code === m.id); + const links = (entry?.links ?? []).map((l) => ({ + code: l.code, + label: l.label, + icon: l.icon, + to: l.to, + })); + groups.push({ + section: m.section, + items: [ + links.length + ? { code: m.id, label: m.label, icon: m.icon, links } + : { code: m.id, label: m.label, icon: m.icon, to: m.to }, + ], + }); + }); + const fpsPages = dedupeBySlug(pagesForRole(FPS_PAGES, role, flags)); if (fpsPages.length) { groups.push({ diff --git a/src/ui/nav/navigation.test.js b/src/ui/nav/navigation.test.js index 19e4323df..b6eb5226c 100644 --- a/src/ui/nav/navigation.test.js +++ b/src/ui/nav/navigation.test.js @@ -66,12 +66,13 @@ describe("buildNavGroups", () => { }); const academics = groups.find((g) => g.section === "Academics"); - expect(linksOf([academics])).toHaveLength(18); + expect(linksOf([academics])).toHaveLength(19); expect(academics.items.map((i) => i.label)).toEqual([ "Registration", "Course Changes", "Student Records", "Calendar & Feedback", + "Fee Structure", ]); const registration = academics.items.find( (item) => item.label === "Registration", @@ -121,7 +122,7 @@ describe("buildNavGroups", () => { expect(groups.map((g) => g.section)).toContain("Academics"); }); - it("shows Bonafide Certificate only to acadadmin", () => { + it("shows the certificate pages only to acadadmin", () => { const acadadmin = buildNavGroups({ role: "acadadmin", accessibleModules: ALL_MODULES, @@ -130,10 +131,20 @@ describe("buildNavGroups", () => { (group) => group.section === "Certificate", ); + // One expandable holding every certificate, as Registration does. expect(certificate.items).toEqual([ expect.objectContaining({ - label: "Bonafide Certificate", - to: "/certificates/bonafide-certificate", + label: "Certificates", + links: [ + expect.objectContaining({ + label: "Bonafide Certificate", + to: "/certificates/bonafide-certificate", + }), + expect.objectContaining({ + label: "Paid / Unpaid Certificate", + to: "/certificates/fee-certificate", + }), + ], }), ]); @@ -334,3 +345,77 @@ describe("department staff", () => { expect(labels).not.toContain("Assistantship"); }); }); + +describe("modules served by another Fusion app", () => { + it("shows Placement Cell only when the IAM granted it", () => { + const withIt = buildNavGroups({ + role: "student", + accessibleModules: { placement_cell: true }, + }); + expect(withIt.map((g) => g.section)).toContain("Placement"); + + const without = buildNavGroups({ role: "student", accessibleModules: {} }); + expect(without.map((g) => g.section)).not.toContain("Placement"); + }); + + it("routes inside this shell, so the sidebar never changes", () => { + const groups = buildNavGroups({ + role: "faculty", + accessibleModules: { leave: true }, + }); + const leave = groups.find((g) => g.section === "Leave").items[0]; + expect(leave.to).toBe("/leave"); + }); +}); + +describe("a plugged module's own screens", () => { + const plugged = [ + { + section: "Placement", + items: [ + { + code: "placement_cell", + label: "Placement Cell", + icon: "Briefcase", + links: [ + { + code: "p.postings", + label: "Opportunities", + icon: "ClipboardText", + to: "/placement/postings", + }, + { + code: "p.offers", + label: "My Offers", + icon: "Signature", + to: "/placement/offers", + }, + ], + }, + ], + }, + ]; + + it("expands into the screens the other service sent", () => { + const groups = buildNavGroups({ + role: "student", + accessibleModules: { placement_cell: true }, + pluggedNavigation: plugged, + }); + const item = groups.find((g) => g.section === "Placement").items[0]; + expect(item.links.map((l) => l.label)).toEqual([ + "Opportunities", + "My Offers", + ]); + }); + + it("stays a single link when the service sent no menu", () => { + const groups = buildNavGroups({ + role: "student", + accessibleModules: { placement_cell: true }, + }); + const item = groups.find((g) => g.section === "Placement").items[0]; + expect(item.to).toBe("/placement"); + expect(item.links).toBeUndefined(); + }); +}); diff --git a/src/ui/routing/PluggedModule.jsx b/src/ui/routing/PluggedModule.jsx new file mode 100644 index 000000000..b4e6834c4 --- /dev/null +++ b/src/ui/routing/PluggedModule.jsx @@ -0,0 +1,39 @@ +import { useLocation } from "react-router-dom"; +import PropTypes from "prop-types"; + +import classes from "./PluggedModule.module.css"; + +/** A module another service serves, rendered inside this shell. */ +const BASE = import.meta.env.VITE_INTEGRATED_BASE || ""; + +export function PluggedModule({ title }) { + const { pathname, search } = useLocation(); + const params = new URLSearchParams(search); + params.set("embed", "1"); + const src = `${BASE}${pathname}?${params}`; + + if (!BASE) { + return ( +

+ {title} is not configured for this environment. +

+ ); + } + + return ( +