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
+ }
+ />
+ }
+ onClick={fetchStudent}
+ disabled={!rollNumber.trim()}
+ loading={fetchingStudent}
+ fullWidth
+ >
+ Fetch student details
+
+ {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(" ")}
+
+ )}
+
+
+ }
+ onClick={showPreview}
+ disabled={!ready}
+ >
+ Preview
+
+ }
+ onClick={download}
+ disabled={!ready}
+ loading={downloading}
+ >
+ Download
+
+
+ }
+ onClick={() => setHistoryOpened(true)}
+ fullWidth
+ >
+ Already Generated
+
+
+
+
+
+
+ {preview ? (
+
+
+
+
+ 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",
+ }),
],
}),
]);