Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/Modules/Examination/components/gradeSheetList.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles["transcript-container"]}>
{error && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
mb="md"
withCloseButton
onClose={() => setError(null)}
>
{error}
</Alert>
)}
{students.length > 0 ? (
<Table
striped
highlightOnHover
captionSide="top"
mt="md"
className={styles["transcript-table"]}
>
<thead>
<tr>
<th>Roll Number</th>
<th>Programme</th>
<th style={{ textAlign: "center" }}>Actions</th>
</tr>
</thead>
<tbody>
{students.map((student) => (
<tr key={student.id_id} className={styles["table-row"]}>
<td className={styles["table-cell"]}>{student.id_id}</td>
<td className={styles["table-cell"]}>{student.programme}</td>
<td style={{ textAlign: "center" }}>
<Group gap="xs" justify="center">
<Button
size="xs"
color="blue"
leftSection={<IconEye size={14} />}
onClick={() => handlePreview(student)}
loading={busy[student.id_id] === "preview"}
>
Preview
</Button>
<Button
size="xs"
color="teal"
variant="outline"
leftSection={<IconDownload size={14} />}
onClick={() => handleDownload(student)}
loading={busy[student.id_id] === "download"}
>
Download
</Button>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<div className="no-data">No students found for this batch.</div>
)}

<Modal
opened={preview.open}
onClose={() => setPreview((p) => ({ ...p, open: false }))}
title={preview.title}
size="90%"
>
<iframe
title="Grade sheet preview"
srcDoc={preview.html}
style={{ width: "100%", height: "80vh", border: "1px solid #ddd" }}
/>
</Modal>
</div>
);
}
238 changes: 238 additions & 0 deletions src/Modules/Examination/components/gradeSheetPdf.js
Original file line number Diff line number Diff line change
@@ -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; }
</style>`,
);

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);
}
Loading
Loading