diff --git a/src/Modules/Examination/components/gradeSheetList.jsx b/src/Modules/Examination/components/gradeSheetList.jsx
new file mode 100644
index 000000000..b3acdb901
--- /dev/null
+++ b/src/Modules/Examination/components/gradeSheetList.jsx
@@ -0,0 +1,134 @@
+import { useState } from "react";
+import { Table, Button, Group, Modal, Alert } from "@mantine/core";
+import { IconEye, IconDownload, IconAlertTriangle } from "@tabler/icons-react";
+import axios from "axios";
+import { generate_full_gradesheet_data } from "../routes/examinationRoutes";
+import { buildFullGradeSheetHTML } from "./gradeSheetTemplate";
+import { saveGradeSheetPDF } from "./gradeSheetPdf";
+import styles from "../styles/transcript.module.css";
+
+export default function GradeSheetList({ students }) {
+ const [busy, setBusy] = useState({});
+ const [error, setError] = useState(null);
+ const [preview, setPreview] = useState({ open: false, html: "", title: "" });
+
+ const setRowBusy = (roll, v) => setBusy((p) => ({ ...p, [roll]: v }));
+
+ const fetchFullGradeSheetHtml = async (rollNo) => {
+ const token = localStorage.getItem("authToken");
+ const { data } = await axios.post(
+ generate_full_gradesheet_data,
+ { student: rollNo },
+ { headers: { Authorization: `Token ${token}` } },
+ );
+ if (!data.semesters || data.semesters.length === 0) {
+ throw new Error("No graded semesters found for this student.");
+ }
+ return buildFullGradeSheetHTML(data.student_info, data.semesters);
+ };
+
+ const handlePreview = async (student) => {
+ setRowBusy(student.id_id, "preview");
+ setError(null);
+ try {
+ const html = await fetchFullGradeSheetHtml(student.id_id);
+ setPreview({ open: true, html, title: `Grade Sheet — ${student.id_id}` });
+ } catch (err) {
+ setError(err.message || "Failed to load grade sheet.");
+ } finally {
+ setRowBusy(student.id_id, null);
+ }
+ };
+
+ const handleDownload = async (student) => {
+ setRowBusy(student.id_id, "download");
+ setError(null);
+ try {
+ const html = await fetchFullGradeSheetHtml(student.id_id);
+ await saveGradeSheetPDF(html, `GradeSheet_${student.id_id}.pdf`);
+ } catch (err) {
+ setError(err.message || "Failed to download grade sheet.");
+ } finally {
+ setRowBusy(student.id_id, null);
+ }
+ };
+
+ return (
+
+ {error && (
+
}
+ mb="md"
+ withCloseButton
+ onClose={() => setError(null)}
+ >
+ {error}
+
+ )}
+ {students.length > 0 ? (
+
+
+
+ Roll Number
+ Programme
+ Actions
+
+
+
+ {students.map((student) => (
+
+ {student.id_id}
+ {student.programme}
+
+
+ }
+ onClick={() => handlePreview(student)}
+ loading={busy[student.id_id] === "preview"}
+ >
+ Preview
+
+ }
+ onClick={() => handleDownload(student)}
+ loading={busy[student.id_id] === "download"}
+ >
+ Download
+
+
+
+
+ ))}
+
+
+ ) : (
+
No students found for this batch.
+ )}
+
+
setPreview((p) => ({ ...p, open: false }))}
+ title={preview.title}
+ size="90%"
+ >
+
+
+
+ );
+}
diff --git a/src/Modules/Examination/components/gradeSheetPdf.js b/src/Modules/Examination/components/gradeSheetPdf.js
new file mode 100644
index 000000000..36a8fd048
--- /dev/null
+++ b/src/Modules/Examination/components/gradeSheetPdf.js
@@ -0,0 +1,238 @@
+import jsPDF from "jspdf";
+import html2canvas from "html2canvas";
+
+const PDF_MARGIN = { top: 75, left: 23, right: 23, bottom: 25 };
+const PAGE_W_MM = 210;
+const PAGE_H_MM = 297;
+const CONTENT_W_MM = PAGE_W_MM - PDF_MARGIN.left - PDF_MARGIN.right;
+const CONTENT_H_MM = PAGE_H_MM - PDF_MARGIN.top - PDF_MARGIN.bottom;
+const CONTENT_W_PX = Math.round(CONTENT_W_MM * (96 / 25.4));
+const CONTENT_H_PX = Math.round(CONTENT_H_MM * (96 / 25.4));
+
+export async function saveGradeSheetPDF(html, filename) {
+ const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
+ let firstPage = true;
+
+ const cleanHTML = html
+ .replace(/@page\b[^{]*\{[^}]*\}/g, "")
+ .replace(/@media\s+print\s*\{[\s\S]*?\}/g, "")
+ .replace(/(:\s*)hidden\b/g, "$1none")
+ .replace(
+ /border-collapse\s*:\s*collapse/g,
+ "border-collapse:separate;border-spacing:0",
+ )
+ .replace(
+ /<\/style>/i,
+ `
+ body { background: #fff !important; }
+`,
+ );
+
+ const iframe = document.createElement("iframe");
+ iframe.style.cssText =
+ `position:fixed;top:-99999px;left:-99999px;` +
+ `width:${CONTENT_W_PX}px;height:10px;border:none;visibility:hidden;`;
+ document.body.appendChild(iframe);
+
+ try {
+ const iDoc = iframe.contentDocument;
+ iDoc.open();
+ iDoc.write(cleanHTML);
+ iDoc.close();
+
+ await new Promise((r) => setTimeout(r, 250));
+ iDoc.body.style.cssText = `margin:0;padding:0;width:${CONTENT_W_PX}px;background:#fff;`;
+ iframe.style.height = `${iDoc.body.scrollHeight}px`;
+ await new Promise((r) => setTimeout(r, 100));
+
+ const shedOverflowingPages = (pageDivs) => {
+ for (let i = 0; i < pageDivs.length; i += 1) {
+ const pageDiv = pageDivs[i];
+ let nextPage = null;
+
+ while (pageDiv.scrollHeight > CONTENT_H_PX) {
+ const semBlocks = Array.from(
+ pageDiv.querySelectorAll(":scope > .sem-block"),
+ );
+ if (semBlocks.length < 2) break;
+
+ if (!nextPage) {
+ const legendBlock = pageDiv.querySelector(":scope > .legend-block");
+ const footerBlock = pageDiv.querySelector(":scope > .footer-block");
+ nextPage = iDoc.createElement("div");
+ nextPage.className = "gs-page";
+ pageDiv.parentNode.insertBefore(nextPage, pageDiv.nextSibling);
+ pageDivs.splice(i + 1, 0, nextPage);
+ if (legendBlock) nextPage.appendChild(legendBlock);
+ if (footerBlock) nextPage.appendChild(footerBlock.cloneNode(true));
+ }
+
+ const lastBlock = semBlocks[semBlocks.length - 1];
+ const gapBox = lastBlock.previousElementSibling;
+ const isGap = gapBox && gapBox.classList.contains("sem-gap");
+ const hadSemBlock = !!nextPage.querySelector(":scope > .sem-block");
+
+ nextPage.insertBefore(lastBlock, nextPage.firstChild);
+ if (isGap) {
+ if (hadSemBlock)
+ nextPage.insertBefore(gapBox, lastBlock.nextSibling);
+ else gapBox.remove();
+ }
+ }
+ }
+ return pageDivs;
+ };
+
+ const hideHeaderRows = (block) => {
+ const ir1 = block.querySelector(".ir1");
+ const ir2 = block.querySelector(".ir2");
+ if (ir1) ir1.style.display = "none";
+ if (ir2) ir2.style.display = "none";
+ };
+ const showHeaderRows = (block) => {
+ const ir1 = block.querySelector(".ir1");
+ const ir2 = block.querySelector(".ir2");
+ if (ir1) ir1.style.display = "";
+ if (ir2) ir2.style.display = "";
+ };
+
+ const allSemBlocks = Array.from(iDoc.querySelectorAll(".sem-block"));
+ allSemBlocks.slice(1).forEach(hideHeaderRows);
+
+ let pageDivs = Array.from(iDoc.querySelectorAll(".gs-page"));
+ pageDivs = shedOverflowingPages(pageDivs);
+
+ pageDivs.slice(1).forEach((pageDiv) => {
+ const firstBlock = pageDiv.querySelector(":scope > .sem-block");
+ if (firstBlock) showHeaderRows(firstBlock);
+ });
+ pageDivs = shedOverflowingPages(pageDivs);
+
+ const completionRows = Array.from(iDoc.querySelectorAll(".completion-row"));
+ const completionText =
+ completionRows[0]?.querySelector("td")?.textContent || "";
+ completionRows.forEach((row) => row.remove());
+
+ pageDivs = Array.from(iDoc.querySelectorAll(".gs-page"));
+ const lastPageDiv = pageDivs[pageDivs.length - 1];
+ const lastLegendTable = lastPageDiv?.querySelector("#legend-table");
+ if (completionText && lastLegendTable) {
+ const tr = iDoc.createElement("tr");
+ const td = iDoc.createElement("td");
+ td.colSpan = 2;
+ td.style.cssText =
+ "font-size:9pt;text-align:center;font-weight:bold;padding:4pt 5pt";
+ td.textContent = completionText;
+ tr.appendChild(td);
+ lastLegendTable.appendChild(tr);
+ }
+
+ pageDivs.forEach((p, idx) => {
+ const pnDiv = p.querySelector(".page-number");
+ if (pnDiv) pnDiv.textContent = `Page ${idx + 1} of ${pageDivs.length}`;
+ });
+
+ pageDivs.forEach((p) => {
+ const footerBlock = p.querySelector(":scope > .footer-block");
+ const signatureTable = footerBlock?.querySelector("table");
+ if (!signatureTable) return;
+ const remaining = CONTENT_H_PX - p.scrollHeight;
+ if (remaining > 0) {
+ const spacer = iDoc.createElement("div");
+ spacer.style.height = `${remaining}px`;
+ signatureTable.parentNode.insertBefore(spacer, signatureTable);
+ }
+ });
+
+ iframe.style.height = `${iDoc.body.scrollHeight}px`;
+ await new Promise((r) => setTimeout(r, 100));
+
+ const BORDER = "1px solid #000";
+ const NONE = "none";
+ const tables = [
+ { sel: "#info-table", type: "box" },
+ { sel: "#course-table", type: "box+header" },
+ { sel: "#spi-table", type: "grid" },
+ { sel: "#gp-table", type: "box+col" },
+ { sel: "#abbr-table", type: "box+col" },
+ { sel: "#ss-table", type: "box+col" },
+ { sel: "#legend-table", type: "box+rows" },
+ ];
+
+ tables.forEach(({ sel, type }) => {
+ const tbls = Array.from(iDoc.querySelectorAll(sel));
+ tbls.forEach((tbl) => {
+ const rows = Array.from(tbl.rows);
+ const lastRowIdx = rows.length - 1;
+
+ rows.forEach((row, rIdx) => {
+ const isFirstRow = rIdx === 0;
+ const isLastRow = rIdx === lastRowIdx;
+ const cells = Array.from(row.cells);
+ const lastColIdx = cells.length - 1;
+
+ cells.forEach((cell, cIdx) => {
+ const normStyle = (cell.getAttribute("style") || "").replace(
+ /\s/g,
+ "",
+ );
+ if (normStyle.includes("border:none")) {
+ cell.style.border = "none";
+ return;
+ }
+ const origHideLeft = normStyle.includes("border-left:none");
+ const isFirstCol = cIdx === 0;
+ const isLastCol = cIdx === lastColIdx;
+
+ let bottomDivider = NONE;
+ let leftDivider = NONE;
+ if (type === "grid") {
+ bottomDivider = isLastRow ? NONE : BORDER;
+ leftDivider = isFirstCol || origHideLeft ? NONE : BORDER;
+ } else if (type === "box+header") {
+ bottomDivider = isFirstRow && !isLastRow ? BORDER : NONE;
+ } else if (type === "box+rows") {
+ bottomDivider = isLastRow ? NONE : BORDER;
+ } else if (type === "box+col") {
+ leftDivider = isFirstCol ? NONE : BORDER;
+ }
+
+ cell.style.borderTop = isFirstRow ? BORDER : NONE;
+ cell.style.borderBottom = isLastRow ? BORDER : bottomDivider;
+ cell.style.borderLeft = isFirstCol ? BORDER : leftDivider;
+ cell.style.borderRight = isLastCol ? BORDER : NONE;
+ });
+ });
+ });
+ });
+
+ const pageNodes = Array.from(iDoc.querySelectorAll(".gs-page"));
+ const nodesToRender = pageNodes.length > 0 ? pageNodes : [iDoc.body];
+
+ for (const node of nodesToRender) {
+ const canvas = await html2canvas(node, {
+ scale: 2,
+ useCORS: true,
+ backgroundColor: "#ffffff",
+ width: CONTENT_W_PX,
+ windowWidth: CONTENT_W_PX,
+ });
+ const imgData = canvas.toDataURL("image/png");
+ const imgH = (canvas.height / canvas.width) * CONTENT_W_MM;
+ if (!firstPage) pdf.addPage();
+ pdf.addImage(
+ imgData,
+ "PNG",
+ PDF_MARGIN.left,
+ PDF_MARGIN.top,
+ CONTENT_W_MM,
+ imgH,
+ );
+ firstPage = false;
+ }
+ } finally {
+ if (document.body.contains(iframe)) document.body.removeChild(iframe);
+ }
+
+ pdf.save(filename);
+}
diff --git a/src/Modules/Examination/components/gradeSheetTemplate.js b/src/Modules/Examination/components/gradeSheetTemplate.js
new file mode 100644
index 000000000..e428bc544
--- /dev/null
+++ b/src/Modules/Examination/components/gradeSheetTemplate.js
@@ -0,0 +1,297 @@
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+
+function toRoman(n) {
+ if (!n || n <= 0 || !Number.isFinite(n)) return String(n ?? "");
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
+ const syms = [
+ "M",
+ "CM",
+ "D",
+ "CD",
+ "C",
+ "XC",
+ "L",
+ "XL",
+ "X",
+ "IX",
+ "V",
+ "IV",
+ "I",
+ ];
+ let r = "";
+ let num = n;
+ for (let i = 0; i < vals.length; i++) {
+ while (num >= vals[i]) {
+ r += syms[i];
+ num -= vals[i];
+ }
+ }
+ return r;
+}
+
+function semesterLabel(sem) {
+ if (sem.is_summer) {
+ const summerNo = Math.floor((sem.semester_no || 0) / 2);
+ return summerNo > 0 ? `Summer ${toRoman(summerNo)}` : "Summer";
+ }
+ return toRoman(sem.semester_no);
+}
+
+function specialSymbolFor(remark) {
+ if (remark === "Substitute") return "S";
+ if (remark === "Backlog" || remark === "Improvement") return "R";
+ return "";
+}
+
+const GRADE_SHEET_CSS = `
+ :root { --fs: 9pt; --fs-sm: 8.5pt; --fs-xs: 8pt; }
+ @page { size: A4; margin-top: 7.5cm; margin-left: 2.3cm; margin-right: 2.3cm; margin-bottom: 2.5cm; }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body { font-family: "Arial MT", "ArialMT", Arial, Helvetica, sans-serif; font-size: var(--fs); color: #000; line-height: 1.0; }
+ .lbl, th, b, strong { font-family: Arial, "Arial MT", Helvetica, sans-serif; }
+ table { width: 100%; border-collapse: collapse; table-layout: fixed; }
+ td, th { border: 1pt solid #000; padding: 0.6pt 3pt; font-size: var(--fs); vertical-align: middle; word-wrap: break-word; overflow-wrap: break-word; }
+ th { font-weight: bold; text-align: center; }
+ .lbl { font-weight: bold; }
+
+ #info-table col.i1 { width: 2.7cm; } #info-table col.i2 { width: 4.3cm; }
+ #info-table col.i3 { width: 2.9cm; } #info-table col.i4 { width: 6.5cm; }
+ #info-table td { vertical-align: top; padding: 1.3pt 5pt; line-height: 1.1; font-size: var(--fs); text-transform: uppercase; }
+ #info-table .lbl { white-space: nowrap; }
+ #info-table .ir1 td { border-bottom: hidden; }
+ #info-table .ir2 td { border-top: hidden; border-bottom: hidden; }
+
+ #course-table col.k1 { width: 13%; } #course-table col.k2 { width: 56%; }
+ #course-table col.k3 { width: 12%; } #course-table col.k4 { width: 9%; } #course-table col.k5 { width: 10%; }
+ #course-table tr:first-child th { border-top: none; }
+ #course-table td { padding: 1.5pt 3pt; }
+
+ #spi-table td { font-size: var(--fs-sm); padding: 1pt 4pt; white-space: nowrap; text-align: center; }
+
+ #gp-table col.gp1 { width: 15%; } #gp-table col.gp2 { width: 85%; }
+ #gp-table td { font-size: var(--fs-xs); line-height: 1.2; padding: 1.5pt 5pt; vertical-align: top; }
+ #gp-table .lbl { font-size: var(--fs-xs); vertical-align: middle; }
+
+ #abbr-table col.ab1 { width: 15%; } #abbr-table col.ab2 { width: 85%; }
+ #abbr-table td { font-size: var(--fs-xs); line-height: 1.2; padding: 1.5pt 5pt; vertical-align: top; }
+ #abbr-table .lbl { font-size: var(--fs-xs); vertical-align: middle; }
+
+ #ss-table col.ss1 { width: 15%; } #ss-table col.ss2 { width: 85%; }
+ #ss-table td { font-size: var(--fs-xs); line-height: 1.2; padding: 1.5pt 5pt; vertical-align: top; }
+ #ss-table .lbl { font-size: var(--fs-xs); vertical-align: middle; }
+
+ #legend-table tr:first-child td { border-top: none; }
+ #legend-table td { font-size: var(--fs-xs); line-height: 1.2; padding: 1.5pt 5pt; vertical-align: top; }
+
+ .gs-page { break-after: page; page-break-after: always; padding-top: 0; }
+ .gs-page:last-child { break-after: auto; page-break-after: auto; }
+
+ @media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
+`;
+
+export function buildFullGradeSheetHTML(studentInfo, semesters) {
+ const isBachelor =
+ (studentInfo.programme || "").toLowerCase().includes("bachelor") ||
+ /^b\.(tech|des)/i.test(studentInfo.programme || "");
+ const minCpi = isBachelor ? "5.0" : "6.5";
+ const creditThreshold = isBachelor ? 148 : 48;
+
+ const pages = [semesters];
+
+ const lastSem = semesters[semesters.length - 1];
+ const graduated =
+ !!lastSem && (lastSem.total_credits || 0) >= creditThreshold;
+ const completionText = `Student has ${graduated ? "" : "not "}successfully completed the programme.`;
+
+ const courseRowsFor = (courses) =>
+ courses
+ .map((c, idx) => {
+ const topHide = idx > 0 ? "border-top:hidden;" : "";
+ const symbol = specialSymbolFor(c.remark);
+ return `
+
+ ${esc(c.code || "")}
+ ${esc(c.name)}
+ ${esc(c.credits)}
+ ${esc(c.grade)}
+ ${esc(symbol)}
+ `;
+ })
+ .join("");
+
+ const semesterBlockHTML = (sem) => {
+ const label = semesterLabel(sem);
+ const isSpecialCourse = (c) => {
+ const g = String(c.grade ?? "").trim();
+ return g !== "" && !Number.isNaN(Number(g));
+ };
+ const hasSpecialCourses = sem.courses.some(isSpecialCourse);
+ const specialCourseCodes = sem.courses
+ .filter(isSpecialCourse)
+ .map((c) => c.code)
+ .join(", ");
+ return `
+
+
+
+ Roll No.
+ ${esc(studentInfo.roll_no)}
+ Programme
+ ${esc(studentInfo.programme)}
+
+
+ Student Name
+ ${esc(studentInfo.name)}
+ Discipline
+ ${esc(studentInfo.discipline)}
+
+
+ Semester
+ ${esc(label)}
+ Academic Year
+ ${esc(sem.academic_year)}
+
+
+
+
+
+
+ Course No.
+ Course Title
+ Unit
+ Grade
+ Special Symbols
+
+${courseRowsFor(sem.courses)}
+
+
+
+
+
+ Result
+ SPI ${Number(sem.spi || 0).toFixed(1)}
+ CPI ${Number(sem.cpi || 0).toFixed(1)}
+
+
+${hasSpecialCourses ? `*In ${esc(specialCourseCodes)} student is awarded SPI based on performance in various evaluation in place of grade.
` : ""}
+`;
+ };
+
+ const legendBlockHTML = () => `
+
+
+
+
+ Grading Points
+
+
+
+
+ O=10 (Distinguished)
+ A+=10 (Outstanding)
+ A=9 (Excellent)
+ B+=8 (Very Good)
+ B=7 (Good)
+
+
+ C+=6 (Average)
+ C=5 (Below Average)
+ D+=4 (Marginal)
+ D=3 (Poor)
+ F=2 (Very Poor)
+
+
+ I=0 (Incomplete)
+ S=0 (Satisfactory)
+ X=0 (Unsatisfactory)
+
+
+
+
+
+
+
+
+
+
+
+ Abbreviations
+
+ SPI : Semester Performance Index
+ CPI : Cumulative Performance Index
+ AU : Indicates that the course has been Audited
+ CD : Indicates that the course has been Dropped due to a shortage of attendance
+
+
+
+
+
+
+
+ Special Symbols
+
+ ‘R’ after letter grade indicates that the course has been Repeated
+ ‘S’ after letter grade indicates that the course has been Substituted
+
+
+
+
+
+
+ • Medium of instruction is English
+ • Conversion from CPI to Percentage using (CPI×10) % formula
+
+
+ Minimum Graduating CPI: ${minCpi}
+ Maximum Graduating CPI: 10.0
+
+
+ ${esc(completionText)}
+
+
+
`;
+
+ const footerBlockHTML = (pageIdx, pageCount) => `
+`;
+
+ const semesterGapHTML = `
+`;
+
+ const pageHTML = (page, pageIdx) => {
+ const isLastPage = pageIdx === pages.length - 1;
+ const blocks = page
+ .map((sem) => `${semesterBlockHTML(sem)}
`)
+ .join(semesterGapHTML);
+ return `
+${blocks}
+${isLastPage ? legendBlockHTML() : ""}
+${footerBlockHTML(pageIdx, pages.length)}
+
`;
+ };
+
+ return `
+
+Grade Sheet - ${esc(studentInfo.roll_no)}
+
+
+${pages.map(pageHTML).join("\n")}
+`;
+}
diff --git a/src/Modules/Examination/examination.jsx b/src/Modules/Examination/examination.jsx
index 29d611567..58aaa1077 100644
--- a/src/Modules/Examination/examination.jsx
+++ b/src/Modules/Examination/examination.jsx
@@ -19,6 +19,7 @@ const COMPONENTS = {
checkResult: lazy(() => import("./checkResult.jsx")),
announceResult: lazy(() => import("./AnnounceResult.jsx")),
generateTranscript: lazy(() => import("./generateTranscript.jsx")),
+ gradeSheet: lazy(() => import("./gradeSheet.jsx")),
};
const StudentTranscript = lazy(
diff --git a/src/Modules/Examination/gradeSheet.jsx b/src/Modules/Examination/gradeSheet.jsx
new file mode 100644
index 000000000..817b0c9be
--- /dev/null
+++ b/src/Modules/Examination/gradeSheet.jsx
@@ -0,0 +1,168 @@
+import { useState, useEffect } from "react";
+import {
+ Alert,
+ Card,
+ Paper,
+ Select,
+ Button,
+ Stack,
+ Group,
+ Box,
+ SimpleGrid,
+ LoadingOverlay,
+ Text,
+} from "@mantine/core";
+import axios from "axios";
+import { useSelector } from "react-redux";
+import GradeSheetList from "./components/gradeSheetList.jsx";
+import { generate_full_gradesheet_form } from "./routes/examinationRoutes.jsx";
+
+export default function GradeSheet() {
+ const userRole = useSelector((state) => state.user.role);
+ const [formData, setFormData] = useState({ batch: "", specialization: "" });
+ const [formOptions, setFormOptions] = useState({
+ batches: [],
+ specializations: [],
+ });
+ const [students, setStudents] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchFormOptions = async () => {
+ const token = localStorage.getItem("authToken");
+ if (!token) {
+ setError("No authentication token found!");
+ return;
+ }
+ try {
+ setLoading(true);
+ const { data } = await axios.get(generate_full_gradesheet_form, {
+ params: { role: userRole },
+ headers: { Authorization: `Token ${token}` },
+ });
+ const batches = (data.batches || []).slice().sort((a, b) => {
+ const yearA = parseInt(
+ (a.label || "").match(/(\d{4})/g)?.pop() || 0,
+ 10,
+ );
+ const yearB = parseInt(
+ (b.label || "").match(/(\d{4})/g)?.pop() || 0,
+ 10,
+ );
+ return yearB - yearA;
+ });
+ setFormOptions({
+ batches: batches.map((batch) => ({
+ value: batch.id.toString(),
+ label: batch.label,
+ })),
+ specializations: (data.specializations || []).map((s) => ({
+ value: s,
+ label: s,
+ })),
+ });
+ } catch (e) {
+ setError(`Error fetching form options: ${e.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchFormOptions();
+ }, [userRole]);
+
+ const handleChange = (field) => (value) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ setStudents(null);
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const token = localStorage.getItem("authToken");
+ if (!token) {
+ setError("No authentication token found!");
+ return;
+ }
+ if (!formData.batch) {
+ setError("Please select a batch.");
+ return;
+ }
+ try {
+ setLoading(true);
+ setError(null);
+ const { data } = await axios.post(
+ generate_full_gradesheet_form,
+ {
+ Role: userRole,
+ batch: formData.batch,
+ specialization: formData.specialization || undefined,
+ },
+ { headers: { Authorization: `Token ${token}` } },
+ );
+ setStudents(data.students || []);
+ } catch (err) {
+ setError(`Error fetching students: ${err.message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Every semester a student has been graded in, one course table per
+ semester, paginated by Academic Year — the institute's official
+ multi-page grade sheet.
+
+
+
+
+ {students !== null && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/Modules/Examination/pages.js b/src/Modules/Examination/pages.js
index dd28dbfb1..1b0799c29 100644
--- a/src/Modules/Examination/pages.js
+++ b/src/Modules/Examination/pages.js
@@ -102,4 +102,12 @@ export const EXAMINATION_PAGES = [
group: null,
roles: [...ACAD, ...PROGRAMME_ADMIN_ROLES],
},
+ {
+ key: "gradeSheet",
+ slug: "grade-sheet",
+ title: "Grade Sheet",
+ icon: "FileText",
+ group: null,
+ roles: [...ACAD, ...PROGRAMME_ADMIN_ROLES],
+ },
];
diff --git a/src/Modules/Examination/routes/examinationRoutes.jsx b/src/Modules/Examination/routes/examinationRoutes.jsx
index 1e8535d69..bebcaf7c5 100644
--- a/src/Modules/Examination/routes/examinationRoutes.jsx
+++ b/src/Modules/Examination/routes/examinationRoutes.jsx
@@ -38,3 +38,5 @@ export const grade_status = `${host}/examination/api/grade_status/`;
export const grade_summary = `${host}/examination/api/grade_summary/`;
export const grade_validation = `${host}/examination/api/grade_validation/`;
export const submit_phd_milestone_grades = `${host}/examination/api/submit_phd_milestone_grades/`;
+export const generate_full_gradesheet_form = `${host}/examination/api/generate_full_gradesheet_form/`;
+export const generate_full_gradesheet_data = `${host}/examination/api/generate_full_gradesheet_data/`;
diff --git a/vite.config.js b/vite.config.js
index 438fa2ded..ea7b156dc 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -13,7 +13,7 @@ export default defineConfig(({ mode }) => ({
"/aims",
"/academic-procedures",
"/programme_curriculum",
- "/examination",
+ "/examination/api",
"/eis",
"/spacs",
"/media",