diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4dad8ac66..a2173580e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -758,4 +758,4 @@ export const Layout: React.FC = ({ )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/PanelViews.tsx b/frontend/src/components/PanelViews.tsx index 0547b8441..52c17a5a1 100644 --- a/frontend/src/components/PanelViews.tsx +++ b/frontend/src/components/PanelViews.tsx @@ -1,11 +1,5 @@ -import { apiFetch } from '../services/apiClient'; -import React, { useState, useEffect } from 'react'; -import { User, UserRole, Student, ClassGroup, School, Worksheet, LogEntry, Ticket } from '../types'; -import { Users, BookOpen, Calendar, ArrowRight, SlidersHorizontal, Layers, Award, MapPin, School as SchoolIcon, BarChart3, FileText, Building2, BookMarked, Globe, Settings, Database, RefreshCw, Search, ChevronDown } from 'lucide-react'; -import { Table, Column } from './Table'; -import { MetricCard } from './Card'; -import { STATE_NAMES, DISTRICT_NAMES, BLOCK_NAMES } from '../constants'; -import { FLN_LEVELS_LIST, parseCSVText, LevelBadge } from './RoleDashboards'; +import React from 'react'; +import { User, UserRole } from '../types'; import { usePanelData } from './panels/usePanelData'; import { AdaptiveTestPanel } from './panels/AdaptiveTestPanel'; import { TestHistoryPanel } from './panels/TestHistoryPanel'; @@ -43,15 +37,6 @@ interface PanelViewsProps { onSelectView?: (view: string) => void; } -const CONTENT_ITEMS = [ - { id: 'c1', title: 'Number Line 1-10', type: 'Visual Aid', level: 'L1-L4', language: 'English, Punjabi', status: 'Approved' }, - { id: 'c2', title: 'Addition with Objects', type: 'Lesson Plan', level: 'L7-L12', language: 'English, Hindi', status: 'Approved' }, - { id: 'c3', title: 'Place Value Chart', type: 'Poster', level: 'L24-L30', language: 'English, Punjabi', status: 'Draft' }, - { id: 'c4', title: 'Multiplication Tables Song', type: 'Audio', level: 'L36-L41', language: 'English', status: 'Review' }, - { id: 'c5', title: 'Fraction Pizza Activity', type: 'Worksheet', level: 'L45-L48', language: 'English, Hindi', status: 'Approved' }, - { id: 'c6', title: 'Money Math Games', type: 'Activity', level: 'L46-L48', language: 'English', status: 'Draft' }, -]; - export const PanelViews: React.FC = ({ activePanel, currentUser, token, onSelectView }) => { const { students, studentsLoading, schools, usersList, reportsList, worksheetsList, teachersList, diff --git a/frontend/src/components/panels/CertificatesPanel.tsx b/frontend/src/components/panels/CertificatesPanel.tsx new file mode 100644 index 000000000..9ce59596a --- /dev/null +++ b/frontend/src/components/panels/CertificatesPanel.tsx @@ -0,0 +1,749 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Award, Printer, Search, ChevronDown } from 'lucide-react'; +import { Student, User, UserRole } from '../../types'; + +type RankedStudent = { + student: Student; + rank: number; + issuedAt?: string; +}; + +const ordinal = (rank: number) => { + const endings = ['th', 'st', 'nd', 'rd']; + const remainder = rank % 100; + return `${rank}${endings[(remainder - 20) % 10] || endings[remainder] || endings[0]}`; +}; + +const printCertificate = async (certificate?: HTMLDivElement | null) => { + if (!certificate) return; + + await document.fonts?.ready; + + const originalParent = certificate.parentNode; + const originalNextSibling = certificate.nextSibling; + + document.body.appendChild(certificate); + + const cleanup = () => { + document.body.classList.remove('printing-certificate'); + document.body.classList.remove('printing-normal-certificate'); + + if (originalParent) { + originalParent.insertBefore( + certificate, + originalNextSibling ?? null + ); + } + }; + + document.body.classList.add('printing-certificate'); + document.body.classList.add('printing-normal-certificate'); + + window.addEventListener('afterprint', cleanup, { once: true }); + window.print(); +}; + +function RankCertificate({ + ranked, + teacherName, + allStudents, + certificateRef, +}: { + ranked: RankedStudent; + teacherName: string; + allStudents: Student[]; + certificateRef?: React.RefObject; +}) { + const issuedAt = ranked.issuedAt + ? new Date(ranked.issuedAt) + : new Date(); + + const issuedDate = issuedAt.toLocaleDateString('en-IN', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + + const weekOfMonth = Math.ceil(issuedAt.getDate() / 7); + const month = issuedAt.toLocaleDateString('en-IN', { + month: 'long', + }); + + const classmates = allStudents + .filter( + student => + student.classGroup === ranked.student.classGroup && + student.section === ranked.student.section + ) + .sort( + (a, b) => + b.currentLevel - a.currentLevel || + b.streak - a.streak || + a.name.localeCompare(b.name) + ); + + const classRank = + classmates.findIndex(student => student.id === ranked.student.id) + 1; + + return ( +
+
+
+ ); +} + +export const CertificatesPanel: React.FC<{ + students: Student[]; + currentUser: User; + variant?: 'all' | 'individual' | 'ranked'; + rankingLimit?: number; + rankingTitle?: string; +}> = ({ students, currentUser, variant = 'all', rankingLimit = 5, rankingTitle }) => { + const [selectedStudentId, setSelectedStudentId] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + const [rankCertificate, setRankCertificate] = + useState(null); + + const certificatePreviewRef = + useRef(null); + + const rankCertificatePreviewRef = + useRef(null); + + const canIssueCertificates = [ + UserRole.TEACHER, + UserRole.VOLUNTEER, + ].includes(currentUser.role); + + useEffect(() => { + if (students.length > 0 && !selectedStudentId) { + setSelectedStudentId(students[0].id); + } + }, [students, selectedStudentId]); + + if (!canIssueCertificates) { + return ( +
+ +

+ Certificate issuing is not available for this role. +

+
+ ); + } + + if (students.length === 0) { + return ( +
+ +

+ No students available. +

+

+ Register a student before issuing certificates. +

+
+ ); + } + + const selectedStudent = + students.find(student => student.id === selectedStudentId) ?? + students[0]; + + const filteredStudents = students.filter(student => + student.name + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + student.id + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + + const isClassOne = + /^class\s*1(?:\D|$)/i.test( + selectedStudent.classGroup.trim() + ); + + const isClassTwo = + /^class\s*2(?:\D|$)/i.test( + selectedStudent.classGroup.trim() + ); + + const topStudents = [...students] + .sort( + (a, b) => + b.currentLevel - a.currentLevel || + b.streak - a.streak || + a.name.localeCompare(b.name) + ) + .slice(0, rankingLimit) + .map((student, index) => ({ + student, + rank: index + 1, + })); + + const handlePrintCertificate = async () => { + if (!certificatePreviewRef.current) return; + + try { + await printCertificate(certificatePreviewRef.current); + } catch (error) { + console.error(error); + alert( + 'The certificate could not be printed. Please try again.' + ); + } + }; + + const printRankCertificate = async () => { + if ( + !rankCertificate || + !rankCertificatePreviewRef.current + ) { + return; + } + + try { + await printCertificate( + rankCertificatePreviewRef.current + ); + } catch (error) { + console.error(error); + alert( + 'The certificate could not be printed. Please try again.' + ); + } + }; + + return ( +
+ {variant === 'all' &&
+

+ Certificates +

+

+ Issue achievement certificates using the current student + records. +

+
} + + {/* Student selector */} + {variant === 'all' &&
+ + + + + {showDropdown && ( +
+
+ + + setSearchQuery(event.target.value) + } + placeholder="Search students..." + className="w-full bg-transparent py-2 text-sm outline-none" + /> +
+ +
+ {filteredStudents.map(student => ( + + ))} +
+
+ )} +
} + + {/* Normal certificate */} + {variant !== 'ranked' &&
+
+
+ +
+ {isClassOne ? ( + <> +
+ ☀ +
+
+ FANTASTIC! +
+
+ Brilliant! +
+
+ ★ ✦ +
+
+ 🏆 +
+ + ) : isClassTwo ? ( + <> +
+ ★ +
+
+ ★ +
+
+ ★ +
+
+ ★ +
+
+ ★ +
+ + ) : ( + <> +
+ ★ +
+
+ ✦ +
+
+ ✶ +
+
+ ✹ +
+
+ ★ +
+ + )} + +

+ FLN Portal · Foundational Literacy & Numeracy +

+ +

+ Certificate of +

+ +

+ Excellence +

+ +

+ This certificate is proudly presented to +

+ +

+ {selectedStudent.name} +

+ +

+ for dedication and progress in foundational literacy + and numeracy. +

+ +
+ {[ + [ + 'Class', + `${selectedStudent.classGroup} - ${selectedStudent.section}`, + ], + [ + 'Current Level', + `L${selectedStudent.currentLevel}.${selectedStudent.currentSubLevel ?? 0}`, + ], + [ + 'Learning Streak', + `${selectedStudent.streak} days`, + ], + ].map(([label, value]) => ( +
+ {label} + + {value} + +
+ ))} +
+ +
+
+ + {new Date().toLocaleDateString('en-IN', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + + Date issued +
+ +
+ + {currentUser.name} + + Teacher signature +
+
+
+
+ +
+ +
+
} + + {/* Ranked students */} + {variant !== 'individual' &&
+
+

+ {rankingTitle ?? `Top ${rankingLimit} Students`} +

+

+ Ranked using current level, learning streak, and name. +

+
+ +
+ {topStudents.map(({ student, rank }) => ( +
+
+ + {rank} + + +
+

+ {student.name} +

+

+ {student.classGroup} · {student.section} +

+
+
+ +
+ + L{student.currentLevel} + + + +
+
+ ))} +
+
} + + {/* Rank certificate modal */} + {rankCertificate && ( +
+
+
+
+

+ Top {rankingLimit} Rank Certificate +

+

+ {rankCertificate.student.name} ·{' '} + {ordinal(rankCertificate.rank)} rank +

+
+ +
+ + + +
+
+ + +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/panels/SchoolsPanel.tsx b/frontend/src/components/panels/SchoolsPanel.tsx index 8fd20199b..f464e7cb7 100644 --- a/frontend/src/components/panels/SchoolsPanel.tsx +++ b/frontend/src/components/panels/SchoolsPanel.tsx @@ -31,13 +31,15 @@ export const SchoolsPanel: React.FC<{ schools: School[] }> = ({ schools }) => {
Showing {filteredSchools.length} of {schools.length} schools
-
{filteredSchools.map(s => ( + {filteredSchools.length === 0 ? ( +
No schools found.
+ ) :
{filteredSchools.map(s => (

{s.name}

{s.strength}
{STATE_NAMES[s.stateCode] || s.stateCode} › {DISTRICT_NAMES[s.districtCode] || s.districtCode} › {BLOCK_NAMES[s.blockCode] || s.blockCode}
👨‍🏫 {s.teachersCount} teachers{s.isAccessLocked ? '🔒 Locked' : '🔓 Active'}
- ))}
+ ))}
}
); }; diff --git a/frontend/src/components/panels/StudentListPanel.tsx b/frontend/src/components/panels/StudentListPanel.tsx index a82a2fe6f..969637add 100644 --- a/frontend/src/components/panels/StudentListPanel.tsx +++ b/frontend/src/components/panels/StudentListPanel.tsx @@ -8,6 +8,7 @@ import { PageHeader, EmptyStudents } from './PanelShared'; import { Users } from 'lucide-react'; import { apiFetch } from '../../services/apiClient'; import { parseCSVText } from '../RoleDashboards'; +import { CertificatesPanel } from './CertificatesPanel'; interface StudentListPanelProps { students: Student[]; @@ -44,6 +45,9 @@ export const StudentListPanel: React.FC = ({ const visibleStudents = activeTab === 'all' ? students : students.filter(s => `${s.classGroup}|${s.section}` === activeTab); + const visibleClassTabs = activeTab === 'all' + ? classTabs + : classTabs.filter(c => `${c.classGroup}|${c.section}` === activeTab); // Student registration states const [showAddForm, setShowAddForm] = useState(false); @@ -501,6 +505,22 @@ export const StudentListPanel: React.FC = ({ )} + + {!studentsLoading && isTeacherOrVolunteer && visibleClassTabs.map(c => { + const classStudents = students.filter( + student => student.classGroup === c.classGroup && student.section === c.section + ); + return ( + + ); + })} ); diff --git a/frontend/src/components/panels/StudentProfilePanel.tsx b/frontend/src/components/panels/StudentProfilePanel.tsx index 83425b396..2e8f3ba2d 100644 --- a/frontend/src/components/panels/StudentProfilePanel.tsx +++ b/frontend/src/components/panels/StudentProfilePanel.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'; import { User, UserRole, Student, School, EvaluationReport, Worksheet } from '../../types'; import { handleDownloadPDF } from './pdfReportGenerator'; import { Users, BookOpen, Calendar, Award, BarChart3, FileText, Search, ChevronDown, GitCompareArrows } from 'lucide-react'; +import { CertificatesPanel } from './CertificatesPanel'; // Issue #200: canonical cycle order for the comparison view — matches // db.ts's CYCLE_NAMES (Worksheet.cycle already uses these exact strings @@ -24,7 +25,7 @@ export const StudentProfilePanel: React.FC<{ updateStudentLocally: (studentId: string, patch: Partial) => void; }> = ({ students, studentsLoading, schools, reportsList, worksheetsList, currentUser, token, updateStudentLocally }) => { const [sel, setSel] = useState(''); - const [profileTab, setProfileTab] = useState<'overview' | 'academic' | 'personal' | 'activity'>('overview'); + const [profileTab, setProfileTab] = useState<'overview' | 'academic' | 'personal' | 'activity' | 'certificate'>('overview'); const [editingProfile, setEditingProfile] = useState(false); const [profileDraft, setProfileDraft] = useState>({}); const [savingProfile, setSavingProfile] = useState(false); @@ -167,6 +168,7 @@ export const StudentProfilePanel: React.FC<{ { key: 'academic' as const, label: 'Academic Record', icon: BookOpen }, { key: 'personal' as const, label: 'Personal Details', icon: Users }, { key: 'activity' as const, label: 'Activity Log', icon: Calendar }, + { key: 'certificate' as const, label: 'Certificate', icon: Award }, ]; return ( @@ -700,6 +702,11 @@ export const StudentProfilePanel: React.FC<{ )} + + {/* ===== CERTIFICATE TAB ===== */} + {profileTab === 'certificate' && ( + + )} ); }; diff --git a/frontend/src/components/panels/SystemSettingsPanel.tsx b/frontend/src/components/panels/SystemSettingsPanel.tsx index 0a4699999..5a09d908f 100644 --- a/frontend/src/components/panels/SystemSettingsPanel.tsx +++ b/frontend/src/components/panels/SystemSettingsPanel.tsx @@ -1,46 +1,18 @@ // Extracted from frontend/src/components/PanelViews.tsx (issue #144, PR 2). import React from 'react'; import { PageHeader } from './PanelShared'; -import { Settings, Database, RefreshCw } from 'lucide-react'; - -const SYSTEM_LOGS_MOCK = [ - { action: 'Database Backup', status: 'Success', timestamp: '2026-07-07 02:00', details: 'Full backup completed (1.2 GB)' }, - { action: 'User Sync', status: 'Success', timestamp: '2026-07-07 01:00', details: 'Synced 142 users from state databases' }, - { action: 'SSL Certificate Renewal', status: 'Success', timestamp: '2026-07-06 12:00', details: 'Wildcard cert renewed, expires 2027-07' }, - { action: 'API Rate Limit Check', status: 'Warning', timestamp: '2026-07-06 10:30', details: '3 endpoints nearing threshold' }, - { action: 'Email Service', status: 'Failed', timestamp: '2026-07-06 08:15', details: 'SMTP relay timeout, retry queued' }, - { action: 'Cache Invalidation', status: 'Success', timestamp: '2026-07-06 06:00', details: 'CDN cache purged for /api/analytics' }, -]; +import { Settings, Database } from 'lucide-react'; export const SystemSettingsPanel: React.FC = () => { return (
} /> -
{[ - { label: 'Platform Name', value: 'National FLN Assessment Portal' }, - { label: 'Version', value: 'v2.4.1 (Build 2026.07)' }, - { label: 'Environment', value: 'Production' }, - { label: 'Database', value: 'PostgreSQL 15.2 / Redis 7.0' }, - { label: 'API Rate Limit', value: '1000 req/min per user' }, - { label: 'Session Timeout', value: '120 minutes' }, - { label: 'Auth Provider', value: 'Email + Password (SLA §3.2)' }, - { label: 'AI Model', value: 'Gemini 1.5 Pro (Fine-tuned FLN)' }, - ].map(c => ( -
{c.label}{c.value}
- ))}
+
No system configuration data is available.
} /> -
{SYSTEM_LOGS_MOCK.map(l => ( -
- - {l.action} - {l.details} - {l.timestamp} -
- ))}
- +
No system health records are available.
); diff --git a/frontend/src/components/panels/TeachersPanel.tsx b/frontend/src/components/panels/TeachersPanel.tsx index 1d49c1ac7..188549af4 100644 --- a/frontend/src/components/panels/TeachersPanel.tsx +++ b/frontend/src/components/panels/TeachersPanel.tsx @@ -12,7 +12,9 @@ export const TeachersPanel: React.FC<{ schools: School[]; teachersList: any[]; c return (
} /> -
{teachersList.map((t: any) => ( + {teachersList.length === 0 ? ( +
No teachers found.
+ ) :
{teachersList.map((t: any) => (
{t.name}
@@ -23,7 +25,7 @@ export const TeachersPanel: React.FC<{ schools: School[]; teachersList: any[]; c
{t.status}
{t.studentsCount} students
- ))}
+ ))}
}
); }; diff --git a/frontend/src/components/panels/UsersPanel.tsx b/frontend/src/components/panels/UsersPanel.tsx index 6995eedf6..6d9a074bf 100644 --- a/frontend/src/components/panels/UsersPanel.tsx +++ b/frontend/src/components/panels/UsersPanel.tsx @@ -65,12 +65,14 @@ export const UsersPanel: React.FC<{ usersList: any[] }> = ({ usersList }) => {
Showing {filteredUsers.length} of {usersList.length} users
-
{filteredUsers.map((u: any) => ( + {filteredUsers.length === 0 ? ( +
No users found.
+ ) :
{filteredUsers.map((u: any) => (
{userDisplayName(u)}
{u.email}
{roleLabel(u.role)}{scopeLabel(u)}Active
- ))}
+ ))}
} ); }; diff --git a/frontend/src/components/panels/usePanelData.ts b/frontend/src/components/panels/usePanelData.ts index 07f2a9c42..31de9c064 100644 --- a/frontend/src/components/panels/usePanelData.ts +++ b/frontend/src/components/panels/usePanelData.ts @@ -3,7 +3,7 @@ // ========================================== // Extracted from frontend/src/components/PanelViews.tsx (issue #144, PR 1 of // the panel-split sequence) so every per-panel file can pull the same -// fetched/fallback data + aggregation helpers without re-fetching or +// fetched data + aggregation helpers without re-fetching or // re-declaring them. See the design proposal on #144 for the full usage map // this hook's return shape is based on. import { useState, useEffect } from 'react'; @@ -22,42 +22,6 @@ const STUDENTS_NOT_NEEDED_PANELS = new Set([ 'system_settings', ]); -const TEACHERS_MOCK = [ - { id: 't1', name: 'Ritu Sharma', email: 'gps-mt-001.t01@fln.org', schoolId: 'gps-mt-001', classes: ['Class 2-A', 'Class 3-A'], studentsCount: 42, delayedAttempts: 0, status: 'Active' }, - { id: 't2', name: 'Amit Kumar', email: 'gps-mt-001.t02@fln.org', schoolId: 'gps-mt-001', classes: ['Class 1-A'], studentsCount: 28, delayedAttempts: 1, status: 'Active' }, - { id: 't3', name: 'Sunita Devi', email: 'gps-bth-006.t01@fln.org', schoolId: 'gps-bth-006', classes: ['Class 2-B', 'Class 4-A'], studentsCount: 35, delayedAttempts: 3, status: 'Suspended' }, - { id: 't4', name: 'Rajesh Kumar', email: 'gps-pkl-008.t01@fln.org', schoolId: 'gps-pkl-008', classes: ['Class 3-B'], studentsCount: 30, delayedAttempts: 0, status: 'Active' }, -]; - -const SCHOOLS_FALLBACK: School[] = [ - { id: 'gps-mt-001', name: 'GPS Model Town', stateCode: 'PB', districtCode: 'LDH', blockCode: 'LDH-01', strength: 'standard', teachersCount: 8, isAccessLocked: false }, - { id: 'gps-vl-002', name: 'GPS Village Lohara', stateCode: 'PB', districtCode: 'MOG', blockCode: 'MOG-01', strength: 'standard', teachersCount: 2, isAccessLocked: false }, - { id: 'gps-amb-003', name: 'GPS Ambala Cantt', stateCode: 'HR', districtCode: 'AMB', blockCode: 'AMB-01', strength: 'standard', teachersCount: 6, isAccessLocked: false }, - { id: 'gps-jai-004', name: 'GPS Govind Dev Ji', stateCode: 'RJ', districtCode: 'JAI', blockCode: 'JAI-01', strength: 'standard', teachersCount: 7, isAccessLocked: true }, - { id: 'gps-lko-005', name: 'GPS Hazratganj', stateCode: 'UP', districtCode: 'LKO', blockCode: 'LKO-01', strength: 'standard', teachersCount: 5, isAccessLocked: false }, - { id: 'gps-bth-006', name: 'GPS Bathinda City', stateCode: 'PB', districtCode: 'BTH', blockCode: 'BTH-01', strength: 'standard', teachersCount: 4, isAccessLocked: false }, - { id: 'gps-asr-007', name: 'GPS Amritsar', stateCode: 'PB', districtCode: 'ASR', blockCode: 'ASR-01', strength: 'standard', teachersCount: 6, isAccessLocked: false }, - { id: 'gps-pkl-008', name: 'GPS Panchkula', stateCode: 'HR', districtCode: 'PKL', blockCode: 'PKL-01', strength: 'standard', teachersCount: 5, isAccessLocked: false }, - { id: 'gps-jai2-009', name: 'GPS Jaipur Rural', stateCode: 'RJ', districtCode: 'JAI', blockCode: 'JAI-02', strength: 'standard', teachersCount: 3, isAccessLocked: false }, - { id: 'gps-uda-010', name: 'GPS Udaipur', stateCode: 'RJ', districtCode: 'UDA', blockCode: 'UDA-01', strength: 'standard', teachersCount: 3, isAccessLocked: false }, - { id: 'gps-lko2-011', name: 'GPS Aliganj', stateCode: 'UP', districtCode: 'LKO', blockCode: 'LKO-02', strength: 'standard', teachersCount: 2, isAccessLocked: false }, - { id: 'gps-knp-012', name: 'GPS Kanpur', stateCode: 'UP', districtCode: 'KNP', blockCode: 'KNP-01', strength: 'standard', teachersCount: 5, isAccessLocked: false }, - { id: 'gps-pb-ldh2-013', name: 'GPS Gill Village', stateCode: 'PB', districtCode: 'LDH', blockCode: 'LDH-02', strength: 'standard', teachersCount: 2, isAccessLocked: false }, - { id: 'gps-hr-amb2-014', name: 'GPS Ambala South', stateCode: 'HR', districtCode: 'AMB', blockCode: 'AMB-02', strength: 'standard', teachersCount: 2, isAccessLocked: false }, -]; - -const USERS_FALLBACK = [ - { name: 'Jinal Gupta', email: 'superadmin@fln.org', role: 'Super Admin', scope: 'National', status: 'Active' }, - { name: 'State Coordinator Punjab', email: 'admin.pb@fln.org', role: 'State Admin', scope: 'PB', status: 'Active' }, - { name: 'State Coordinator Haryana', email: 'admin.hr@fln.org', role: 'State Admin', scope: 'HR', status: 'Active' }, - { name: 'Ludhiana District Officer', email: 'district.ldh@fln.org', role: 'District Admin', scope: 'PB-LDH', status: 'Active' }, - { name: 'Ambala District Officer', email: 'district.amb@fln.org', role: 'District Admin', scope: 'HR-AMB', status: 'Active' }, - { name: 'Ludhiana Block Admin 1', email: 'block.ldh-01@fln.org', role: 'Block Admin', scope: 'PB-LDH-LDH-01', status: 'Active' }, - { name: 'GPS Model Town Principal', email: 'gps-mt-001@fln.org', role: 'Principal', scope: 'gps-mt-001', status: 'Active' }, - { name: 'Ritu Sharma', email: 'gps-mt-001.t01@fln.org', role: 'Teacher', scope: 'gps-mt-001', status: 'Active' }, - { name: 'Rahul Kumar', email: 'vol.rahul@fln.org', role: 'Volunteer', scope: 'Moga Villages', status: 'Active' }, -]; - export function usePanelData(token: string, currentUser: User, activePanel: string) { const [apiStudents, setApiStudents] = useState([]); // Distinct from apiStudents.length === 0 — issue #292: that condition is @@ -117,15 +81,11 @@ export function usePanelData(token: string, currentUser: User, activePanel: stri }, [token, activePanel, apiStudents.length, studentsUrl]); const students = apiStudents; - const schools = apiSchools.length > 0 ? apiSchools : SCHOOLS_FALLBACK; - const usersList = apiUsers.length > 0 ? apiUsers : USERS_FALLBACK; - // No mock fallback here (unlike students/schools/users): a fake report's - // studentId (e.g. 's1') will never match a real student in `students`, - // which rendered as a literal "Unknown" name - showing an empty list on - // fetch failure is honest, a mismatched fake report is not. + const schools = apiSchools; + const usersList = apiUsers; const reportsList: EvaluationReport[] = apiReports; const worksheetsList: Worksheet[] = apiWorksheets; - const teachersList = apiTeachers.length > 0 ? apiTeachers : TEACHERS_MOCK; + const teachersList = apiTeachers; // Real per-district / per-block rollups, derived from the already-fetched // schools + students (no dedicated aggregation endpoint exists). diff --git a/frontend/src/index.css b/frontend/src/index.css index 78b47483e..833cf985c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Kalam:wght@700&family=Luxurious+Script&family=Caveat:wght@700&display=swap'); @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @@ -11,6 +11,8 @@ @theme { --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; + --font-script: "Luxurious Script", cursive; + --font-hand: "Caveat", cursive; /* Primary theme palette for the FLN portal */ --color-primary-navy: #0F2E5A; /* Deep Navy */ @@ -33,6 +35,111 @@ html.dark, html.dark body { color: #f1f5f9; } +/* Print the certificate that is already visible in the portal, and nothing + else. This keeps the saved PDF visually identical to the preview. */ +@media print { + @page { + size: A4 landscape; + margin: 0; + } + + html, + body.printing-certificate { + width: 297mm !important; + height: 210mm !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + background: #fff !important; + } + + body.printing-certificate * { + visibility: hidden !important; + } + + body.printing-certificate [data-certificate-preview], + body.printing-certificate [data-certificate-preview] * { + visibility: visible !important; + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + body.printing-certificate [data-certificate-preview] { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 297mm !important; + min-width: 297mm !important; + max-width: 297mm !important; + height: 210mm !important; + min-height: 210mm !important; + max-height: 210mm !important; + margin: 0 !important; + box-sizing: border-box !important; + aspect-ratio: auto !important; + border-radius: 0 !important; + box-shadow: none !important; + overflow: hidden !important; + break-inside: avoid !important; + page-break-inside: avoid !important; + page-break-after: avoid !important; + } + + /* The normal certificate is temporarily attached directly to before + printing. Remove the dashboard's hidden top-level layout from pagination + and anchor only this certificate at the physical page origin. */ + body.printing-normal-certificate > :not([data-certificate-preview]) { + display: none !important; + } + + body.printing-normal-certificate > [data-certificate-preview] { + position: absolute !important; + inset: 0 auto auto 0 !important; + } +} + +/* Top-five rank certificate: navy-and-gold formal certificate layout. + Its canvas uses the exact A4 landscape ratio on screen and in the PDF. */ +.rank-certificate { width: 100%; aspect-ratio: 297 / 210; overflow: hidden; background: #fff; color: #1a1a1a; font-family: Georgia, 'Times New Roman', serif; } +.rank-certificate__paper { position: relative; height: 92%; margin: 4%; overflow: hidden; background: #fff; border: 3px solid #000; } +.rank-certificate__paper::before { content: ''; position: absolute; inset: 3%; z-index: 1; border: 1px solid #33538a; pointer-events: none; } +.rank-certificate__paper::after { content: ''; position: absolute; inset: 3.6%; z-index: 1; border: 1px solid #33538a; pointer-events: none; } +.rank-certificate__corner { position: absolute; z-index: 2; width: 24%; aspect-ratio: 1; overflow: hidden; pointer-events: none; } +.rank-certificate__corner--tl { top: -2px; left: -2px; } +.rank-certificate__corner--br { bottom: -2px; right: -2px; transform: rotate(180deg); } +.rank-certificate__corner::before { content: ''; position: absolute; top: -45%; left: -45%; width: 150%; height: 65%; background: #33538a; transform: rotate(-45deg); } +.rank-certificate__corner::after { content: ''; position: absolute; top: -28%; left: -58%; width: 150%; height: 11%; background: #e0b23c; transform: rotate(-45deg); } +.rank-certificate__content { position: relative; z-index: 3; display: flex; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 0.6%; padding: 4% 12%; text-align: center; box-sizing: border-box; } +.rank-certificate__portal { margin: 0; color: #0f766e; font-family: Arial, sans-serif; font-size: clamp(7px, 0.75vw, 10px); font-weight: 700; letter-spacing: 0.25em; text-transform: uppercase; } +.rank-certificate__title { display: flex; flex-direction: column; gap: 0.15em; margin: 0; } +.rank-certificate__title-main { font-family: Georgia, 'Times New Roman', serif; font-size: clamp(26px, 5.2vw, 58px); font-weight: 700; line-height: 1; letter-spacing: 0.05em; color: #0d1321; text-transform: uppercase; } +.rank-certificate__title-sub { font-family: Arial, sans-serif; font-size: clamp(11px, 1.6vw, 20px); font-weight: 500; letter-spacing: 0.18em; color: #33538a; text-transform: uppercase; } +.rank-certificate__awarded { margin: 1.5% 0 0; font-family: Arial, sans-serif; font-size: clamp(9px, 1.05vw, 14px); letter-spacing: 0.12em; text-transform: uppercase; color: #333; } +.rank-certificate__name { margin: 1% 0 0; font-family: Arial, sans-serif; font-size: clamp(20px, 3.6vw, 42px); font-weight: 700; color: #2f4f8f; } +.rank-certificate__divider { width: 65%; height: 1px; margin: 1.8% auto 0; background: #c7d2e0; } +.rank-certificate__achievement { margin: 1.6% 0 0; font-family: Arial, sans-serif; font-size: clamp(9px, 1.1vw, 15px); color: #333; } +.rank-certificate__facts { display: flex; justify-content: center; gap: 6%; width: 100%; margin: 1.2% 0 0; font-family: Arial, sans-serif; font-size: clamp(7px, 0.85vw, 12px); text-transform: uppercase; color: #555; } +.rank-certificate__facts span { display: flex; flex-direction: column; gap: 2px; } +.rank-certificate__facts b { color: #1a1a1a; font-size: 1em; } +.rank-certificate__bottom { display: flex; align-items: flex-end; justify-content: center; gap: 6%; width: 100%; margin-top: 2.2%; } +.rank-certificate__signatures { display: flex; flex: 1; justify-content: center; max-width: 32%; } +.rank-certificate__signatures div { display: flex; flex-direction: column; gap: 4px; width: 100%; text-align: center; } +.rank-certificate__signatures strong { min-height: 1em; border-bottom: 1px solid #999; padding-bottom: 4px; font-family: Arial, sans-serif; font-size: clamp(9px, 1vw, 14px); font-weight: 600; color: #1a1a1a; } +.rank-certificate__signatures span { font-family: Arial, sans-serif; font-size: clamp(7px, 0.75vw, 11px); letter-spacing: 0.1em; text-transform: uppercase; color: #666; } +.rank-certificate__medal { position: relative; z-index: 4; width: 13%; aspect-ratio: 287 / 336; flex-shrink: 0; } +.rank-certificate__medal-ribbon { position: absolute; left: 0; top: 43.5%; z-index: 1; width: 100%; height: 56.5%; } +.rank-certificate__medal-ring-outer { position: absolute; top: 0; left: 50%; z-index: 2; width: 72.5%; aspect-ratio: 1; transform: translateX(-50%); border-radius: 50%; background: linear-gradient(155deg, #ffedb0, #ffcf4d); box-shadow: inset 0 0 0 2px rgba(255,255,255,0.6); } +.rank-certificate__medal-ring-inner { position: absolute; top: 6.8%; left: 50%; z-index: 3; width: 56.8%; aspect-ratio: 1; transform: translateX(-50%); border: 3px solid #f5a623; border-radius: 50%; box-sizing: border-box; } +.rank-certificate__medal-circle { position: absolute; top: 11.6%; left: 50%; z-index: 4; display: flex; width: 45.6%; aspect-ratio: 1; align-items: center; justify-content: center; transform: translateX(-50%); border-radius: 50%; background: #0D99FF; } +.rank-certificate__medal-circle span { font-family: 'Kalam', cursive; font-size: clamp(18px, 2.6vw, 32px); font-weight: 700; color: #1e1e1e; } + +/* Yellow star with a white outline, used on the Class 2 certificate */ +.certificate-star { + color: #facc15; + -webkit-text-stroke: 1.5px #fff; + paint-order: stroke fill; +} + /* Custom class for top indicator ribbon */ .tricolor-ribbon { height: 5px;