diff --git a/src/Modules/Academic/FeeStructure.jsx b/src/Modules/Academic/FeeStructure.jsx index f440fe1f0..117a16f22 100644 --- a/src/Modules/Academic/FeeStructure.jsx +++ b/src/Modules/Academic/FeeStructure.jsx @@ -18,9 +18,17 @@ import { TextInput, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; -import { Copy, Info, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; +import { + Bank, + Copy, + Info, + PencilSimple, + Plus, + Trash, +} from "@phosphor-icons/react"; import { + demandLetterBankAccountsRoute, feeStructureDetailRoute, feeStructureReplicateRoute, feeStructureTemplateRoute, @@ -99,6 +107,8 @@ export default function FeeStructure() { const [replicating, setReplicating] = useState(null); const [category, setCategory] = useState("UG"); const [preview, setPreview] = useState(null); + const [bankDraft, setBankDraft] = useState(null); + const [bankSaving, setBankSaving] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -193,6 +203,42 @@ export default function FeeStructure() { } }; + const openBankDetails = async () => { + try { + const { data } = await axios.get( + demandLetterBankAccountsRoute, + authConfig(), + ); + setBankDraft(data); + } catch (error) { + notifications.show({ + color: "red", + title: "Could not load bank account details", + message: message(error, "Please try again."), + }); + } + }; + + const saveBankDetails = async () => { + setBankSaving(true); + try { + await axios.put(demandLetterBankAccountsRoute, bankDraft, authConfig()); + notifications.show({ + color: "green", + message: "Bank account details saved.", + }); + setBankDraft(null); + } catch (error) { + notifications.show({ + color: "red", + title: "Not saved", + message: message(error, "Please try again."), + }); + } finally { + setBankSaving(false); + } + }; + const visible = useMemo( () => rows.filter((row) => row.programme_category === category), [rows, category], @@ -212,12 +258,21 @@ export default function FeeStructure() { ))} - + + + + @@ -338,6 +393,59 @@ export default function FeeStructure() { onClose={() => setDraft(null)} /> )} + + {bankDraft && ( + setBankDraft(null)} + title="Bank Account Details for the Demand Letter" + size="lg" + centered + > + + + One shared record -- printed on every Demand Letter, for every + programme and every academic year. + + + + setBankDraft((current) => ({ + ...current, + academic_fee_account: { + ...current.academic_fee_account, + [field]: value, + }, + })) + } + /> + + setBankDraft((current) => ({ + ...current, + mess_fee_account: { + ...current.mess_fee_account, + [field]: value, + }, + })) + } + /> + + + + + + + + )} ); } @@ -791,6 +899,55 @@ function AmountField({ value, semesters, allowPerSemester, onChange }) { ); } +function BankAccountFields({ heading, account, onChange }) { + return ( + + + {heading} + + onChange("name", event.currentTarget.value)} + /> + onChange("number", event.currentTarget.value)} + /> + onChange("ifsc", event.currentTarget.value)} + /> + onChange("bank_branch", event.currentTarget.value)} + /> + + onChange("account_type", event.currentTarget.value) + } + /> + + ); +} + +BankAccountFields.propTypes = { + heading: PropTypes.string.isRequired, + account: PropTypes.shape({ + name: PropTypes.string, + number: PropTypes.string, + ifsc: PropTypes.string, + bank_branch: PropTypes.string, + account_type: PropTypes.string, + }).isRequired, + onChange: PropTypes.func.isRequired, +}; + function NotesEditor({ notes, onChange }) { return ( diff --git a/src/Modules/Certificates/BonafideCertificate.module.css b/src/Modules/Certificates/BonafideCertificate.module.css index 4e7c271b0..54b024e77 100644 --- a/src/Modules/Certificates/BonafideCertificate.module.css +++ b/src/Modules/Certificates/BonafideCertificate.module.css @@ -135,3 +135,80 @@ font-size: 12.5pt; line-height: 16pt; } + +.certificateSmall { + font-size: 12pt; +} + +.certificateSmall .heading { + font-size: 13.5pt; +} + +.demandFeeTable { + width: auto; +} + +.demandFeeTable td { + white-space: nowrap; +} + +.bankTables { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin: 0.3rem 0 0; +} + +.bankTableHeading { + margin: 0 0 4pt; + text-align: center; + font-weight: 700; +} + +.bankTable { + border-collapse: collapse; + width: 100%; +} + +.bankTable td { + border: 1px solid #000; + padding: 2.5pt 6pt; + font-size: 11pt; + line-height: 14pt; +} + +.bankTable td:first-child { + font-weight: 700; + width: 30%; +} + +.structureDetails { + display: grid; + grid-template-columns: auto auto auto 1fr; + column-gap: 10pt; + row-gap: 4pt; + margin: 0 0 8pt; +} + +.structureDetailsSpan { + grid-column: 2 / span 3; +} + +.structureTable { + border-collapse: collapse; + width: 100%; +} + +.structureTable td, +.structureTable th { + border: 1px solid #000; + padding: 2.5pt 6pt; + font-size: 12pt; + line-height: 15pt; + text-align: left; +} + +.structureTable th { + white-space: nowrap; + font-weight: 700; +} diff --git a/src/Modules/Certificates/DemandLetter.jsx b/src/Modules/Certificates/DemandLetter.jsx new file mode 100644 index 000000000..2e5e18c8b --- /dev/null +++ b/src/Modules/Certificates/DemandLetter.jsx @@ -0,0 +1,500 @@ +import { useState } from "react"; +import axios from "axios"; +import { saveAs } from "file-saver"; +import { + Alert, + Badge, + Button, + Divider, + Grid, + Group, + Loader, + Paper, + Select, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { notifications } from "@mantine/notifications"; +import { + ClockCounterClockwise, + DownloadSimple, + Eye, + Info, + MagnifyingGlass, + Warning, +} from "@phosphor-icons/react"; + +import { + demandLetterPdfRoute, + demandLetterStudentRoute, +} 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 { + const parsed = JSON.parse(await payload.text()); + return [parsed.error, ...(parsed.details ?? [])] + .filter(Boolean) + .join(" "); + } catch { + return "Certificate generation failed."; + } + } + return ( + [payload?.error, ...(payload?.details ?? [])].filter(Boolean).join(" ") || + payload?.message || + payload?.detail || + "Unable to complete the request." + ); +}; + +const filenameFrom = (header, fallback) => { + const match = header?.match(/filename="?([^";]+)"?/i); + return match?.[1] || fallback; +}; + +function ordinalWithSuperscript(value) { + const match = /^(\d+)(st|nd|rd|th)$/.exec((value ?? "").trim()); + if (!match) return value; + return ( + <> + {match[1]} + {match[2]} + + ); +} + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const ordinal = (value) => { + const mod100 = value % 100; + if (mod100 >= 10 && mod100 <= 20) return `${value}th`; + const suffix = { 1: "st", 2: "nd", 3: "rd" }[value % 10] || "th"; + return `${value}${suffix}`; +}; + +const toISODate = (date) => { + if (!date) return ""; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const formatDueDate = (date) => { + if (!date) return ""; + return `${MONTHS[date.getMonth()]} ${String(date.getDate()).padStart(2, "0")}, ${date.getFullYear()}`; +}; + +export default function DemandLetter() { + const [rollNumber, setRollNumber] = useState(""); + const [selectedStudent, setSelectedStudent] = useState(null); + const [semesters, setSemesters] = useState([]); + const [selectedSemester, setSelectedSemester] = useState(null); + const [academicYearLabel, setAcademicYearLabel] = useState(""); + const [demand, setDemand] = useState(null); + const [bank, setBank] = useState(null); + const [dueDate, setDueDate] = 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); + setSemesters([]); + setSelectedSemester(null); + setAcademicYearLabel(""); + setDemand(null); + setBank(null); + setDueDate(null); + setCertificateMeta(null); + setPreview(null); + }; + + const fetchStudent = async (roll, semester) => { + if (!roll || fetchingStudent) return; + setFetchingStudent(true); + try { + const { data } = await axios.get(demandLetterStudentRoute, { + ...authConfig(), + params: { roll_number: roll, semester: semester || undefined }, + }); + setSelectedStudent(data.student || null); + setSemesters( + (data.semesters || []).map((item) => ({ + value: String(item.value), + label: item.label, + })), + ); + setSelectedSemester(data.selected_semester ?? null); + setAcademicYearLabel(data.academic_year_label || ""); + setDemand(data.demand || null); + setBank(data.bank || null); + setCertificateMeta(data.certificate || null); + setPreview(null); + } catch (error) { + notifications.show({ + color: "red", + title: "Student unavailable", + message: await responseMessage(error), + }); + } finally { + setFetchingStudent(false); + } + }; + + const searchStudent = () => { + const normalizedRollNumber = rollNumber.trim().toUpperCase(); + if (!normalizedRollNumber) return; + clearStudent(); + setRollNumber(normalizedRollNumber); + fetchStudent(normalizedRollNumber); + }; + + const changeSemester = (value) => { + const semester = Number(value); + setSelectedSemester(semester); + setPreview(null); + fetchStudent(rollNumber, semester); + }; + + const showPreview = () => { + if (!selectedStudent || !demand || !dueDate || !certificateMeta) return; + setPreview({ + student: selectedStudent, + demand, + bank, + selectedSemester, + academicYearLabel, + dueDateDisplay: formatDueDate(dueDate), + meta: certificateMeta, + }); + }; + + const download = async () => { + if (!selectedStudent || !demand || !dueDate || downloading) return; + setDownloading(true); + try { + const response = await axios.post( + demandLetterPdfRoute, + { + student_id: selectedStudent.student_id, + semester: selectedSemester, + due_date: toISODate(dueDate), + }, + { ...authConfig(), responseType: "blob" }, + ); + const filename = filenameFrom( + response.headers["content-disposition"], + `${selectedStudent.roll_number}_Demand_Letter.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 && demand && dueDate && selectedSemester, + ); + + return ( + <> + + + + +
+ Generate demand letter + + Enter a roll number to fetch the student details. + +
+ + { + setRollNumber(event.currentTarget.value.toUpperCase()); + clearStudent(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + searchStudent(); + } + }} + 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="Cannot issue this certificate" + > + {selectedStudent.validation_errors.join(" ")} + + )} + + {semesters.length > 0 && ( +