Skip to content
Open
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
30 changes: 23 additions & 7 deletions src/Modules/Certificates/GeneratedCertificatesModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
feeCertificatesRoute,
feeStructureCertificateHistoryPdfRoute,
feeStructureCertificatesRoute,
mediumOfInstructionCertificatePdfRoute,
mediumOfInstructionCertificatesRoute,
} from "../../routes/academicRoutes";
import classes from "./GeneratedCertificatesModal.module.css";

Expand Down Expand Up @@ -70,6 +72,10 @@ const ROUTES_BY_VARIANT = {
list: feeStructureCertificatesRoute,
pdf: feeStructureCertificateHistoryPdfRoute,
},
mediumOfInstruction: {
list: mediumOfInstructionCertificatesRoute,
pdf: mediumOfInstructionCertificatePdfRoute,
},
};

export default function GeneratedCertificatesModal({
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -194,7 +201,10 @@ export default function GeneratedCertificatesModal({
<Table.Td>{certificate.serial_number}</Table.Td>
<Table.Td>{certificate.roll_number}</Table.Td>
<Table.Td>{certificate.name}</Table.Td>
<Table.Td>{certificate.purpose}</Table.Td>
<Table.Td>
{certificate.purpose ||
(variant === "mediumOfInstruction" ? "Medium of Instruction" : "—")}
</Table.Td>
<Table.Td>{certificate.issued_on}</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
Expand Down Expand Up @@ -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 = {
Expand Down
322 changes: 322 additions & 0 deletions src/Modules/Certificates/MediumOfInstructionCertificate.jsx
Original file line number Diff line number Diff line change
@@ -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]}
<sup>{match[2]}</sup>
</>
);
}

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 (
<>
<Grid gutter="lg" className={classes.layout}>
<Grid.Col span={{ base: 12, xl: 4 }}>
<Paper withBorder p="lg" radius="md" className={classes.controls}>
<Stack gap="md">
<div>
<Title order={3}>Generate certificate</Title>
<Text c="dimmed" size="sm" mt={4}>
Enter a roll number to fetch the student details.
</Text>
</div>
<Divider />
<TextInput
label="Roll number"
placeholder="Enter roll number"
value={rollNumber}
onChange={(event) => {
setRollNumber(event.currentTarget.value.toUpperCase());
clearStudent();
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
fetchStudent();
}
}}
rightSection={
fetchingStudent ? <Loader size={16} /> : undefined
}
/>
<Button
variant="light"
leftSection={<MagnifyingGlass size={18} />}
onClick={fetchStudent}
disabled={!rollNumber.trim()}
loading={fetchingStudent}
fullWidth
>
Fetch student details
</Button>
{selectedStudent && (
<Paper withBorder p="md" radius="sm">
<Group justify="space-between" mb="xs">
<Text fw={600}>{selectedStudent.name}</Text>
<Badge color={selectedStudent.is_ready ? "green" : "red"}>
{selectedStudent.is_ready ? "Ready" : "Incomplete"}
</Badge>
</Group>
<Text size="sm">Roll No.: {selectedStudent.roll_number}</Text>
<Text size="sm">
{selectedStudent.programme} in {selectedStudent.discipline}
</Text>
<Text size="sm">
{selectedStudent.year_ordinal} Year,{" "}
{selectedStudent.semester_ordinal} Semester
</Text>
</Paper>
)}

{selectedStudent && !selectedStudent.is_ready && (
<Alert
color="red"
icon={<Warning size={18} />}
title="Student data incomplete"
>
{selectedStudent.validation_errors.join(" ")}
</Alert>
)}

<Group grow>
<Button
variant="default"
leftSection={<Eye size={18} />}
onClick={showPreview}
disabled={!ready}
>
Preview
</Button>
<Button
leftSection={<DownloadSimple size={18} />}
onClick={download}
disabled={!ready}
loading={downloading}
>
Download
</Button>
</Group>
<Button
variant="light"
leftSection={<ClockCounterClockwise size={18} />}
onClick={() => setHistoryOpened(true)}
fullWidth
>
Already Generated
</Button>
</Stack>
</Paper>
</Grid.Col>

<Grid.Col span={{ base: 12, xl: 8 }}>
{preview ? (
<div className={classes.previewViewport}>
<article className={classes.certificate}>
<header className={classes.certificateHeader}>
<div>
<div>{preview.meta.signatory_name}</div>
<div>{preview.meta.signatory_title}</div>
</div>
<div className={classes.reference}>
<div>{preview.meta.reference_preview}</div>
<div>Date: {preview.meta.issued_on}</div>
</div>
</header>
<h2 className={classes.heading}>
TO WHOM SO EVER IT MAY CONCERN
</h2>
<p className={classes.body}>
This is to certify that{" "}
<strong>
{preview.student.salutation} {preview.student.name}
</strong>{" "}
(Roll No. {preview.student.roll_number}){" "}
{preview.student.relation}{" "}
<strong>MR. {preview.student.father_name}</strong> is a
student of{" "}
<strong>
{ordinalWithSuperscript(preview.student.year_ordinal)} Year
</strong>{" "}
({ordinalWithSuperscript(preview.student.semester_ordinal)}{" "}
Semester) <strong>{preview.student.programme}</strong> in{" "}
<strong>{preview.student.discipline}</strong> ({" "}
{preview.student.duration_text} course duration:{" "}
<strong>{preview.student.start_year}</strong> to{" "}
<strong>{preview.student.end_year}</strong>) at{" "}
{preview.meta.institute_name}.
</p>
<p className={`${classes.body} ${classes.secondParagraph}`}>
Institute provides education mode of instruction to the
students in English only.
</p>
<div className={classes.signature}>
({preview.meta.signatory_name})
</div>
</article>
</div>
) : (
<Alert
color="blue"
icon={<Info size={18} />}
title="Certificate preview"
>
Enter a roll number, fetch the student, and choose Preview. The
certificate can then be printed or downloaded as PDF.
</Alert>
)}
</Grid.Col>
</Grid>
<GeneratedCertificatesModal
opened={historyOpened}
onClose={() => setHistoryOpened(false)}
variant="mediumOfInstruction"
/>
</>
);
}
Loading