diff --git a/src/Modules/Certificates/GeneratedCertificatesModal.jsx b/src/Modules/Certificates/GeneratedCertificatesModal.jsx index 66a9c0556..928126d42 100644 --- a/src/Modules/Certificates/GeneratedCertificatesModal.jsx +++ b/src/Modules/Certificates/GeneratedCertificatesModal.jsx @@ -28,6 +28,8 @@ import { feeCertificatesRoute, feeStructureCertificateHistoryPdfRoute, feeStructureCertificatesRoute, + mediumOfInstructionCertificatePdfRoute, + mediumOfInstructionCertificatesRoute, } from "../../routes/academicRoutes"; import classes from "./GeneratedCertificatesModal.module.css"; @@ -70,6 +72,10 @@ const ROUTES_BY_VARIANT = { list: feeStructureCertificatesRoute, pdf: feeStructureCertificateHistoryPdfRoute, }, + mediumOfInstruction: { + list: mediumOfInstructionCertificatesRoute, + pdf: mediumOfInstructionCertificatePdfRoute, + }, }; export default function GeneratedCertificatesModal({ @@ -145,7 +151,7 @@ export default function GeneratedCertificatesModal({ }); setPdfPreview({ url: URL.createObjectURL(response.data), - title: `${certificate.roll_number} — ${certificate.purpose}`, + title: `${certificate.roll_number} — ${certificate.purpose || "Medium of Instruction"}`, }); } catch (error) { notifications.show({ @@ -166,12 +172,13 @@ export default function GeneratedCertificatesModal({ params: { download: 1 }, responseType: "blob", }); + const defaultName = + variant === "mediumOfInstruction" + ? `${certificate.roll_number}_Medium_of_Instruction_Certificate_${certificate.serial_number}.pdf` + : `${certificate.roll_number}_Bonafide_Certificate_${certificate.serial_number}.pdf`; saveAs( response.data, - filenameFrom( - response.headers["content-disposition"], - `${certificate.roll_number}_Bonafide_Certificate_${certificate.serial_number}.pdf`, - ), + filenameFrom(response.headers["content-disposition"], defaultName), ); } catch (error) { notifications.show({ @@ -194,7 +201,10 @@ export default function GeneratedCertificatesModal({ {certificate.serial_number} {certificate.roll_number} {certificate.name} - {certificate.purpose} + + {certificate.purpose || + (variant === "mediumOfInstruction" ? "Medium of Instruction" : "—")} + {certificate.issued_on} @@ -325,7 +335,13 @@ export default function GeneratedCertificatesModal({ GeneratedCertificatesModal.propTypes = { opened: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, - variant: PropTypes.oneOf(["bonafide", "fee", "demand", "feeStructure"]), + variant: PropTypes.oneOf([ + "bonafide", + "fee", + "demand", + "feeStructure", + "mediumOfInstruction", + ]), }; GeneratedCertificatesModal.defaultProps = { diff --git a/src/Modules/Certificates/MediumOfInstructionCertificate.jsx b/src/Modules/Certificates/MediumOfInstructionCertificate.jsx new file mode 100644 index 000000000..f02d9df53 --- /dev/null +++ b/src/Modules/Certificates/MediumOfInstructionCertificate.jsx @@ -0,0 +1,322 @@ +import { useState } from "react"; +import axios from "axios"; +import { saveAs } from "file-saver"; +import { + Alert, + Badge, + Button, + Divider, + Grid, + Group, + Loader, + Paper, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { + ClockCounterClockwise, + DownloadSimple, + Eye, + Info, + MagnifyingGlass, + Warning, +} from "@phosphor-icons/react"; + +import { + mediumOfInstructionPdfRoute, + mediumOfInstructionStudentRoute, +} from "../../routes/academicRoutes"; +import classes from "./BonafideCertificate.module.css"; +import GeneratedCertificatesModal from "./GeneratedCertificatesModal"; + +const authConfig = () => ({ + headers: { + Authorization: `Token ${localStorage.getItem("authToken")}`, + }, +}); + +const responseMessage = async (error) => { + const payload = error?.response?.data; + if (payload instanceof Blob) { + try { + return JSON.parse(await payload.text()).error; + } catch { + return "Certificate generation failed."; + } + } + return ( + payload?.error || + payload?.message || + payload?.detail || + "Unable to complete the request." + ); +}; + +const filenameFrom = (header, fallback) => { + const match = header?.match(/filename="?([^";]+)"?/i); + return match?.[1] || fallback; +}; + +// Renders an ordinal such as "4th" with the suffix raised, as the PDF does. +function ordinalWithSuperscript(value) { + const match = /^(\d+)(st|nd|rd|th)$/.exec((value ?? "").trim()); + if (!match) return value; + return ( + <> + {match[1]} + {match[2]} + + ); +} + +export default function MediumOfInstructionCertificate() { + const [rollNumber, setRollNumber] = useState(""); + const [selectedStudent, setSelectedStudent] = useState(null); + const [certificateMeta, setCertificateMeta] = useState(null); + const [preview, setPreview] = useState(null); + const [fetchingStudent, setFetchingStudent] = useState(false); + const [downloading, setDownloading] = useState(false); + const [historyOpened, setHistoryOpened] = useState(false); + + const clearStudent = () => { + setSelectedStudent(null); + setCertificateMeta(null); + setPreview(null); + }; + + const fetchStudent = async () => { + const normalizedRollNumber = rollNumber.trim().toUpperCase(); + if (!normalizedRollNumber || fetchingStudent) return; + + clearStudent(); + setRollNumber(normalizedRollNumber); + setFetchingStudent(true); + try { + const { data } = await axios.get(mediumOfInstructionStudentRoute, { + ...authConfig(), + params: { roll_number: normalizedRollNumber }, + }); + setSelectedStudent(data.student || null); + setCertificateMeta(data.certificate || null); + } catch (error) { + notifications.show({ + color: "red", + title: "Student unavailable", + message: await responseMessage(error), + }); + } finally { + setFetchingStudent(false); + } + }; + + const showPreview = () => { + if (!selectedStudent || !certificateMeta) return; + setPreview({ + student: selectedStudent, + meta: certificateMeta, + }); + }; + + const download = async () => { + if (!selectedStudent || downloading) return; + setDownloading(true); + try { + const response = await axios.post( + mediumOfInstructionPdfRoute, + { + student_id: selectedStudent.student_id, + }, + { ...authConfig(), responseType: "blob" }, + ); + const filename = filenameFrom( + response.headers["content-disposition"], + `${selectedStudent.roll_number}_Medium_of_Instruction_Certificate.pdf`, + ); + saveAs(response.data, filename); + notifications.show({ + color: "green", + title: "Certificate downloaded", + message: response.headers["x-certificate-reference"] || filename, + }); + } catch (error) { + notifications.show({ + color: "red", + title: "Download failed", + message: await responseMessage(error), + }); + } finally { + setDownloading(false); + } + }; + + const ready = Boolean(selectedStudent?.is_ready); + + return ( + <> + + + + +
+ Generate certificate + + Enter a roll number to fetch the student details. + +
+ + { + setRollNumber(event.currentTarget.value.toUpperCase()); + clearStudent(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + fetchStudent(); + } + }} + rightSection={ + fetchingStudent ? : undefined + } + /> + + {selectedStudent && ( + + + {selectedStudent.name} + + {selectedStudent.is_ready ? "Ready" : "Incomplete"} + + + Roll No.: {selectedStudent.roll_number} + + {selectedStudent.programme} in {selectedStudent.discipline} + + + {selectedStudent.year_ordinal} Year,{" "} + {selectedStudent.semester_ordinal} Semester + + + )} + + {selectedStudent && !selectedStudent.is_ready && ( + } + title="Student data incomplete" + > + {selectedStudent.validation_errors.join(" ")} + + )} + + + + + + +
+
+
+ + + {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}. +

+

+ Institute provides education mode of instruction to the + students in English only. +

+
+ ({preview.meta.signatory_name}) +
+
+
+ ) : ( + } + title="Certificate preview" + > + Enter a roll number, fetch the student, and choose Preview. The + certificate can then be printed or downloaded as PDF. + + )} +
+
+ setHistoryOpened(false)} + variant="mediumOfInstruction" + /> + + ); +} diff --git a/src/Modules/Certificates/index.jsx b/src/Modules/Certificates/index.jsx index 6771bd8c5..57745708c 100644 --- a/src/Modules/Certificates/index.jsx +++ b/src/Modules/Certificates/index.jsx @@ -10,6 +10,9 @@ const COMPONENTS = { feeCertificate: lazy(() => import("./FeeCertificate")), demandLetter: lazy(() => import("./DemandLetter")), feeStructureCertificate: lazy(() => import("./FeeStructureCertificate")), + mediumOfInstructionCertificate: lazy( + () => import("./MediumOfInstructionCertificate"), + ), }; export default function Certificates() { diff --git a/src/Modules/Certificates/pages.js b/src/Modules/Certificates/pages.js index 3e5e44c1d..9aaf4f483 100644 --- a/src/Modules/Certificates/pages.js +++ b/src/Modules/Certificates/pages.js @@ -37,4 +37,13 @@ export const CERTIFICATE_PAGES = [ roles: ["acadadmin"], desktopOnly: true, }, + { + key: "mediumOfInstructionCertificate", + slug: "medium-of-instruction", + title: "Medium of Instruction", + icon: "Scroll", + group: "Certificates", + roles: ["acadadmin"], + desktopOnly: true, + }, ]; diff --git a/src/routes/academicRoutes/index.jsx b/src/routes/academicRoutes/index.jsx index cb73c8c86..1d1abec25 100644 --- a/src/routes/academicRoutes/index.jsx +++ b/src/routes/academicRoutes/index.jsx @@ -58,6 +58,12 @@ export const feeStructureCertificatesRoute = `${host}/academic-procedures/api/ac export const feeStructureCertificateHistoryPdfRoute = (certificateId) => `${feeStructureCertificatesRoute}${certificateId}/pdf/`; +export const mediumOfInstructionStudentRoute = `${host}/academic-procedures/api/acad/medium-of-instruction/student/`; +export const mediumOfInstructionPdfRoute = `${host}/academic-procedures/api/acad/medium-of-instruction/pdf/`; +export const mediumOfInstructionCertificatesRoute = `${host}/academic-procedures/api/acad/medium-of-instruction/certificates/`; +export const mediumOfInstructionCertificatePdfRoute = (certificateId) => + `${mediumOfInstructionCertificatesRoute}${certificateId}/pdf/`; + export const feeStructuresRoute = `${host}/academic-procedures/api/acad/fee-structures/`; export const feeStructureTemplateRoute = `${feeStructuresRoute}template/`; export const feeStructureDetailRoute = (structureId) => diff --git a/src/ui/nav/navigation.test.js b/src/ui/nav/navigation.test.js index 65af5fcba..c44ed456c 100644 --- a/src/ui/nav/navigation.test.js +++ b/src/ui/nav/navigation.test.js @@ -167,6 +167,10 @@ describe("buildNavGroups", () => { label: "Fee Structure Certificate", to: "/certificates/fee-structure-certificate", }), + expect.objectContaining({ + label: "Medium of Instruction", + to: "/certificates/medium-of-instruction", + }), ], }), ]);