diff --git a/backend/src/db.ts b/backend/src/db.ts index 8beae54f..94753cec 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -17,16 +17,16 @@ export let mongoClient: MongoClient | null = null; export const connectDB = async () => { const uri = process.env.MONGODB_URI; if (!uri) { - console.error("MONGODB_URI not set — cannot start server"); - process.exit(1); + console.warn("MONGODB_URI not set — running with file-based DB only"); + return; } try { mongoClient = new MongoClient(uri); await mongoClient.connect(); console.log("MongoDB Connected"); - } catch (err) { - console.error("MongoDB connection failed:", err.message); - process.exit(1); + } catch (err: any) { + console.warn("MongoDB connection failed — falling back to file-based DB:", err.message); + mongoClient = null; } }; @@ -292,6 +292,7 @@ const COLLECTION_NAMES: Record = { students: 'students', questions: 'questions', worksheets: 'worksheets', + levelWorksheets: 'level_worksheets', answerSubmissions: 'answer_submissions', evaluationReports: 'evaluation_reports', tickets: 'tickets', @@ -343,14 +344,18 @@ export class DBStore { } private async persistCollection(key: keyof DatabaseSchema) { - if (!this.data || !mongoClient) return; - const db = this.getDb(); - const collName = COLLECTION_NAMES[key]; - const items = (this.data as any)[key] || []; - const coll = db.collection(collName); - await coll.deleteMany({}); - if (items.length > 0) { - await coll.insertMany(items); + if (!this.data) return; + if (mongoClient) { + const db = this.getDb(); + const collName = COLLECTION_NAMES[key]; + const items = (this.data as any)[key] || []; + const coll = db.collection(collName); + await coll.deleteMany({}); + if (items.length > 0) { + await coll.insertMany(items); + } + } else { + await this.save(); } } @@ -378,195 +383,302 @@ export class DBStore { return this.data.users.find(u => u.email.toLowerCase() === email.toLowerCase()) || null; } + private collection(key: keyof DatabaseSchema, collName: string): T[] { + return ((this.data as any)?.[key] || []) as T[]; + } + async getUsers() { - return await this.mongoDb!.collection('users').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('users').find({}).toArray(); + return this.collection('users', 'users'); } async getSchools() { - return await this.mongoDb!.collection('schools').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('schools').find({}).toArray(); + return this.collection('schools', 'schools'); } async getClasses() { - return await this.mongoDb!.collection('classes').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('classes').find({}).toArray(); + return this.collection('classes', 'classes'); } async getStudents() { - return await this.mongoDb!.collection('students').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('students').find({}).toArray(); + return this.collection('students', 'students'); } async getQuestions() { - return await this.mongoDb!.collection('questions').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('questions').find({}).toArray(); + return this.collection('questions', 'questions'); } async getWorksheets() { - return await this.mongoDb!.collection('worksheets').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('worksheets').find({}).toArray(); + return this.collection('worksheets', 'worksheets'); } async getLevelWorksheets() { - return await this.mongoDb!.collection('levelWorksheets').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('levelWorksheets').find({}).toArray(); + return this.collection('levelWorksheets', 'levelWorksheets'); } async getAnswerSubmissions() { - return await this.mongoDb!.collection('answerSubmissions').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('answerSubmissions').find({}).toArray(); + return this.collection('answerSubmissions', 'answerSubmissions'); } async getEvaluationReports() { - return await this.mongoDb!.collection('evaluationReports').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('evaluationReports').find({}).toArray(); + return this.collection('evaluationReports', 'evaluationReports'); } async getTickets() { - return await this.mongoDb!.collection('tickets').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('tickets').find({}).toArray(); + return this.collection('tickets', 'tickets'); } async getLogbook() { - return await this.mongoDb!.collection('logbook').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('logbook').find({}).toArray(); + return this.collection('logbook', 'logbook'); } async getAnnouncements() { - return await this.mongoDb!.collection('announcements').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('announcements').find({}).toArray(); + return this.collection('announcements', 'announcements'); } // --- Write / Update Helpers --- async addUser(user: User) { - await this.mongoDb!.collection('users').insertOne(user); + if (this.mongoDb) await this.mongoDb.collection('users').insertOne(user); if (this.data) this.data.users.push(user); + if (!this.mongoDb) await this.save(); return user; } async addStudent(student: Student) { - await this.mongoDb!.collection('students').insertOne(student); + if (this.mongoDb) await this.mongoDb.collection('students').insertOne(student); if (this.data) this.data.students.push(student); + if (!this.mongoDb) await this.save(); return student; } async updateStudent(studentId: string, updates: Partial) { - await this.mongoDb!.collection('students').updateOne({ id: studentId }, { $set: updates }); - const s = await this.mongoDb!.collection('students').findOne({ id: studentId }); - if (s && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('students').updateOne({ id: studentId }, { $set: updates }); + const s = await this.mongoDb.collection('students').findOne({ id: studentId }); + if (s && this.data) { + const idx = this.data.students.findIndex(x => x.id === studentId); + if (idx !== -1) this.data.students[idx] = s; + } + return s || undefined; + } + if (this.data) { const idx = this.data.students.findIndex(x => x.id === studentId); - if (idx !== -1) this.data.students[idx] = s; + if (idx !== -1) { + this.data.students[idx] = { ...this.data.students[idx], ...updates } as Student; + await this.save(); + return this.data.students[idx]; + } } - return s || undefined; + return undefined; } async addWorksheet(ws: Worksheet) { - await this.mongoDb!.collection('worksheets').insertOne(ws); + if (this.mongoDb) await this.mongoDb.collection('worksheets').insertOne(ws); if (this.data) this.data.worksheets.push(ws); + if (!this.mongoDb) await this.save(); return ws; } async updateWorksheet(worksheetId: string, updates: Partial) { - await this.mongoDb!.collection('worksheets').updateOne({ id: worksheetId }, { $set: updates }); - const ws = await this.mongoDb!.collection('worksheets').findOne({ id: worksheetId }); - if (ws && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('worksheets').updateOne({ id: worksheetId }, { $set: updates }); + const ws = await this.mongoDb.collection('worksheets').findOne({ id: worksheetId }); + if (ws && this.data) { + const idx = this.data.worksheets.findIndex(x => x.id === worksheetId); + if (idx !== -1) this.data.worksheets[idx] = ws; + } + return ws || undefined; + } + if (this.data) { const idx = this.data.worksheets.findIndex(x => x.id === worksheetId); - if (idx !== -1) this.data.worksheets[idx] = ws; + if (idx !== -1) { + this.data.worksheets[idx] = { ...this.data.worksheets[idx], ...updates } as Worksheet; + await this.save(); + return this.data.worksheets[idx]; + } } - return ws || undefined; + return undefined; } async addLevelWorksheet(ws: LevelWorksheet) { - await this.mongoDb!.collection('levelWorksheets').insertOne(ws); + if (this.mongoDb) await this.mongoDb.collection('levelWorksheets').insertOne(ws); if (this.data) this.data.levelWorksheets.push(ws); + if (!this.mongoDb) await this.save(); return ws; } async addAnswerSubmission(sub: AnswerSubmission) { - await this.mongoDb!.collection('answerSubmissions').insertOne(sub); + if (this.mongoDb) await this.mongoDb.collection('answerSubmissions').insertOne(sub); if (this.data) this.data.answerSubmissions.push(sub); + if (!this.mongoDb) await this.save(); return sub; } async addEvaluationReport(rep: EvaluationReport) { - await this.mongoDb!.collection('evaluationReports').insertOne(rep); + if (this.mongoDb) await this.mongoDb.collection('evaluationReports').insertOne(rep); if (this.data) this.data.evaluationReports.push(rep); + if (!this.mongoDb) await this.save(); return rep; } async addTicket(t: Ticket) { - await this.mongoDb!.collection('tickets').insertOne(t); + if (this.mongoDb) await this.mongoDb.collection('tickets').insertOne(t); if (this.data) this.data.tickets.push(t); + if (!this.mongoDb) await this.save(); return t; } async updateTicket(id: string, updates: Partial) { - await this.mongoDb!.collection('tickets').updateOne({ id }, { $set: updates }); - const t = await this.mongoDb!.collection('tickets').findOne({ id }); - if (t && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('tickets').updateOne({ id }, { $set: updates }); + const t = await this.mongoDb.collection('tickets').findOne({ id }); + if (t && this.data) { + const idx = this.data.tickets.findIndex(x => x.id === id); + if (idx !== -1) this.data.tickets[idx] = t; + } + return t || undefined; + } + if (this.data) { const idx = this.data.tickets.findIndex(x => x.id === id); - if (idx !== -1) this.data.tickets[idx] = t; + if (idx !== -1) { + this.data.tickets[idx] = { ...this.data.tickets[idx], ...updates } as Ticket; + await this.save(); + return this.data.tickets[idx]; + } } - return t || undefined; + return undefined; } async updateUser(userId: string, updates: Partial) { - await this.mongoDb!.collection('users').updateOne({ id: userId }, { $set: updates }); - const u = await this.mongoDb!.collection('users').findOne({ id: userId }); - if (u && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('users').updateOne({ id: userId }, { $set: updates }); + const u = await this.mongoDb.collection('users').findOne({ id: userId }); + if (u && this.data) { + const idx = this.data.users.findIndex(x => x.id === userId); + if (idx !== -1) this.data.users[idx] = u; + } + return u || undefined; + } + if (this.data) { const idx = this.data.users.findIndex(x => x.id === userId); - if (idx !== -1) this.data.users[idx] = u; + if (idx !== -1) { + this.data.users[idx] = { ...this.data.users[idx], ...updates } as User; + await this.save(); + return this.data.users[idx]; + } } - return u || undefined; + return undefined; } async updateSchool(schoolId: string, updates: Partial) { - await this.mongoDb!.collection('schools').updateOne({ id: schoolId }, { $set: updates }); - const s = await this.mongoDb!.collection('schools').findOne({ id: schoolId }); - if (s && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('schools').updateOne({ id: schoolId }, { $set: updates }); + const s = await this.mongoDb.collection('schools').findOne({ id: schoolId }); + if (s && this.data) { + const idx = this.data.schools.findIndex(x => x.id === schoolId); + if (idx !== -1) this.data.schools[idx] = s; + } + return s || undefined; + } + if (this.data) { const idx = this.data.schools.findIndex(x => x.id === schoolId); - if (idx !== -1) this.data.schools[idx] = s; + if (idx !== -1) { + this.data.schools[idx] = { ...this.data.schools[idx], ...updates } as School; + await this.save(); + return this.data.schools[idx]; + } } - return s || undefined; + return undefined; } async addSchool(school: School) { - await this.mongoDb!.collection('schools').insertOne(school); + if (this.mongoDb) await this.mongoDb.collection('schools').insertOne(school); if (this.data) this.data.schools.push(school); + if (!this.mongoDb) await this.save(); return school; } async addLog(log: LogEntry) { - await this.mongoDb!.collection('logbook').insertOne(log); + if (this.mongoDb) await this.mongoDb.collection('logbook').insertOne(log); if (this.data) this.data.logbook.unshift(log); + if (!this.mongoDb) await this.save(); return log; } async addAnnouncement(ann: Announcement) { - await this.mongoDb!.collection('announcements').insertOne(ann); + if (this.mongoDb) await this.mongoDb.collection('announcements').insertOne(ann); if (this.data) this.data.announcements.unshift(ann); + if (!this.mongoDb) await this.save(); return ann; } // --- Intervention & Best Practice Methods --- async getInterventions() { - return await this.mongoDb!.collection('interventions').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('interventions').find({}).toArray(); + return this.collection('interventions', 'interventions'); } async addIntervention(intervention: Intervention) { - await this.mongoDb!.collection('interventions').insertOne(intervention); + if (this.mongoDb) await this.mongoDb.collection('interventions').insertOne(intervention); if (this.data) this.data.interventions.push(intervention); + if (!this.mongoDb) await this.save(); return intervention; } async updateIntervention(id: string, updates: Partial) { - await this.mongoDb!.collection('interventions').updateOne({ id }, { $set: updates }); - const i = await this.mongoDb!.collection('interventions').findOne({ id }); - if (i && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('interventions').updateOne({ id }, { $set: updates }); + const i = await this.mongoDb.collection('interventions').findOne({ id }); + if (i && this.data) { + const idx = this.data.interventions.findIndex(x => x.id === id); + if (idx !== -1) this.data.interventions[idx] = i; + } + return i || undefined; + } + if (this.data) { const idx = this.data.interventions.findIndex(x => x.id === id); - if (idx !== -1) this.data.interventions[idx] = i; + if (idx !== -1) { + this.data.interventions[idx] = { ...this.data.interventions[idx], ...updates } as Intervention; + await this.save(); + return this.data.interventions[idx]; + } } - return i || undefined; + return undefined; } async getBestPractices() { - return await this.mongoDb!.collection('bestPractices').find({}).toArray(); + if (this.mongoDb) return await this.mongoDb.collection('bestPractices').find({}).toArray(); + return this.collection('bestPractices', 'bestPractices'); } async addBestPractice(bp: BestPractice) { - await this.mongoDb!.collection('bestPractices').insertOne(bp); + if (this.mongoDb) await this.mongoDb.collection('bestPractices').insertOne(bp); if (this.data) this.data.bestPractices.push(bp); + if (!this.mongoDb) await this.save(); return bp; } async updateBestPractice(id: string, updates: Partial) { - await this.mongoDb!.collection('bestPractices').updateOne({ id }, { $set: updates }); - const bp = await this.mongoDb!.collection('bestPractices').findOne({ id }); - if (bp && this.data) { + if (this.mongoDb) { + await this.mongoDb.collection('bestPractices').updateOne({ id }, { $set: updates }); + const bp = await this.mongoDb.collection('bestPractices').findOne({ id }); + if (bp && this.data) { + const idx = this.data.bestPractices.findIndex(x => x.id === id); + if (idx !== -1) this.data.bestPractices[idx] = bp; + } + return bp || undefined; + } + if (this.data) { const idx = this.data.bestPractices.findIndex(x => x.id === id); - if (idx !== -1) this.data.bestPractices[idx] = bp; + if (idx !== -1) { + this.data.bestPractices[idx] = { ...this.data.bestPractices[idx], ...updates } as BestPractice; + await this.save(); + return this.data.bestPractices[idx]; + } } - return bp || undefined; + return undefined; } // --- Preloaded Question Pool (Mathematical Curriculum Questions Classes 2-4) --- diff --git a/backend/src/index.ts b/backend/src/index.ts index a1e8dafa..e500e59a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -72,24 +72,28 @@ async function startServer() { // --- API Endpoints --- + // Health check endpoint (no auth required) + app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }); + }); + // Public stats (no auth required — used by landing page) app.get('/api/stats', async (_req, res) => { - const db = dbStore.getDb(); - if (!db) return res.json({ totalStates: 0, totalDistricts: 0, totalSchools: 0, totalStudents: 0, totalAssessments: 0, avgFlnLevel: 0, totalUsers: 0, certifiedCount: 0, certifiedPercent: 0 }); - - const [totalSchools, totalStudents, totalUsers, totalAssessments, stateCodes, districtCodes, avgResult, certifiedResult] = await Promise.all([ - db.collection('schools').countDocuments(), - db.collection('students').countDocuments(), - db.collection('users').countDocuments(), - db.collection('worksheets').countDocuments(), - db.collection('schools').distinct('stateCode'), - db.collection('schools').distinct('districtCode'), - db.collection('students').aggregate([{ $group: { _id: null, avg: { $avg: '$currentLevel' } } }]).toArray(), - db.collection('students').aggregate([{ $match: { currentLevel: { $gte: 5 } } }, { $count: 'count' }]).toArray(), + const [schools, students, users, worksheets] = await Promise.all([ + dbStore.getSchools(), + dbStore.getStudents(), + dbStore.getUsers(), + dbStore.getWorksheets(), ]); - const certifiedCount = certifiedResult[0]?.count ?? 0; - const avgFlnLevel = totalStudents > 0 ? Math.round(avgResult[0]?.avg ?? 0) : 0; + const totalSchools = schools.length; + const totalStudents = students.length; + const totalUsers = users.length; + const totalAssessments = worksheets.length; + const stateCodes = [...new Set(schools.map((s: any) => s.stateCode).filter(Boolean))]; + const districtCodes = [...new Set(schools.map((s: any) => s.districtCode).filter(Boolean))]; + const avgFlnLevel = totalStudents > 0 ? Math.round(students.reduce((sum: number, s: any) => sum + (s.currentLevel || 0), 0) / totalStudents) : 0; + const certifiedCount = students.filter((s: any) => s.currentLevel >= 5).length; res.json({ totalStates: stateCodes.length, @@ -2071,6 +2075,11 @@ async function startServer() { res.json({ ...bp, viewCount: (bp.viewCount || 0) + 1 }); }); + // Catch-all: any unmatched API route gets a proper 404 instead of hanging + app.use('/api/*', (_req, res) => { + res.status(404).json({ error: 'Endpoint not found' }); + }); + // In development, serve the frontend using Vite development middleware. // In production, serve the built frontend bundle (frontend/dist). if (process.env.NODE_ENV !== "production") { diff --git a/backend/src/paperGenerator.ts b/backend/src/paperGenerator.ts index cff68552..b6220d71 100644 --- a/backend/src/paperGenerator.ts +++ b/backend/src/paperGenerator.ts @@ -217,7 +217,9 @@ export async function generateLevelWorksheet({ await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' as any, timeout: 30000 }); const data = await page.evaluate(({ levelId, subIdx, studentId, studentName }) => { + // @ts-expect-error page.evaluate runs in browser context with DOM APIs const nameInput = document.getElementById('studentName') as HTMLInputElement | null; + // @ts-expect-error page.evaluate runs in browser context with DOM APIs const idInput = document.getElementById('studentId') as HTMLInputElement | null; if (nameInput) nameInput.value = studentName; if (idInput) idInput.value = studentId; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f19a723c..3783305d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import { apiFetch } from './services/apiClient'; * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Route, Routes, useNavigate } from 'react-router-dom'; import { Announcement, User, UserRole } from './types'; import CoordinatorRegistration from './pages/CoordinatorRegistration'; @@ -22,7 +22,10 @@ import { LogbookView } from './components/LogbookView'; import { TicketSubmission } from './components/TicketSubmission'; import { AssessmentCalendar } from './components/AssessmentCalendar'; import { PanelViews } from './components/PanelViews'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { Bell, Settings, ShieldCheck } from 'lucide-react'; +import { SessionTimeout } from './components/SessionTimeout'; +import { KeyboardShortcuts, useKeyboardShortcuts } from './components/KeyboardShortcuts'; export default function App() { const navigate = useNavigate(); @@ -32,6 +35,10 @@ export default function App() { const [activePanel, setActivePanel] = useState('workspace'); const [announcements, setAnnouncements] = useState([]); const [toast, setToast] = useState(null); + const [showShortcuts, setShowShortcuts] = useState(false); + const [selectedStudentId, setSelectedStudentId] = useState(null); + + useKeyboardShortcuts(() => setShowShortcuts(p => !p)); const triggerToast = (msg: string) => { setToast(msg); @@ -87,13 +94,13 @@ export default function App() { const handleClearNotifications = () => setAnnouncements([]); - const handleLogout = () => { + const handleLogout = useCallback(() => { setToken(null); setCurrentUser(null); localStorage.removeItem('fln_token'); setCurrentView('home'); navigate('/'); - }; + }, [navigate]); const renderRoleWorkspace = () => { if (!currentUser) return null; @@ -129,9 +136,12 @@ export default function App() { {currentView === 'dashboard' && currentUser && token && ( + + + )} {toast && ( @@ -238,6 +250,11 @@ export default function App() { )} )} + + {currentView === 'dashboard' && currentUser && token && ( + + )} + setShowShortcuts(false)} /> } /> diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..22b6b45f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,59 @@ +import React, { useState, useEffect, type ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallbackTitle?: string; +} + +export const ErrorBoundary: React.FC = ({ children, fallbackTitle }) => { + const [error, setError] = useState(null); + + useEffect(() => { + const handleError = (event: ErrorEvent) => { + event.preventDefault(); + setError(event.error instanceof Error ? event.error : new Error(String(event.error))); + }; + const handleRejection = (event: PromiseRejectionEvent) => { + event.preventDefault(); + setError(new Error(String(event.reason))); + }; + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleRejection); + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleRejection); + }; + }, []); + + if (error) { + return ( +
+
+ +
+

+ {fallbackTitle || 'Something went wrong'} +

+

+ An unexpected error occurred while rendering this section. + You can try again or navigate to a different page. +

+ {error.message && ( +

+ {error.message} +

+ )} + +
+ ); + } + + return <>{children}; +}; diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx new file mode 100644 index 00000000..4412a43f --- /dev/null +++ b/frontend/src/components/KeyboardShortcuts.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react'; +import { Keyboard, X } from 'lucide-react'; + +interface KeyboardShortcutsProps { + visible: boolean; + onClose: () => void; +} + +const SHORTCUTS = [ + { category: 'Navigation', items: [ + { keys: ['/', '⌘K'], description: 'Open student search' }, + { keys: ['Esc'], description: 'Close search / dropdown / modal' }, + { keys: ['?'], description: 'Toggle this shortcuts panel' }, + ]}, + { category: 'Student Profile', items: [ + { keys: ['1'], description: 'Overview tab' }, + { keys: ['2'], description: 'Academic Record tab' }, + { keys: ['3'], description: 'Personal Details tab' }, + { keys: ['4'], description: 'Activity Log tab' }, + ]}, + { category: 'General', items: [ + { keys: ['⌘/Ctrl', 'L'], description: 'Focus sidebar search' }, + { keys: ['T'], description: 'Toggle dark/light mode' }, + { keys: ['P'], description: 'Print current view' }, + ]}, +]; + +export const KeyboardShortcuts: React.FC = ({ visible, onClose }) => { + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && visible) onClose(); + }; + if (visible) { + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + } + }, [visible, onClose]); + + if (!visible) return null; + + return ( +
+
e.stopPropagation()} + > +
+
+ +

Keyboard Shortcuts

+
+ +
+ +
+ {SHORTCUTS.map(group => ( +
+

+ {group.category} +

+
+ {group.items.map(item => ( +
+ {item.description} +
+ {item.keys.map((key, i) => ( + + {i > 0 && +} + + {key} + + + ))} +
+
+ ))} +
+
+ ))} +
+ +
+ Press ? or Esc to close +
+
+
+ ); +}; + +export const useKeyboardShortcuts = (onToggle: () => void) => { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if (e.key === '?' || (e.shiftKey && e.key === '/')) { + e.preventDefault(); + onToggle(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onToggle]); +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bccc9509..7be3e85a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,9 +2,10 @@ import React, { useState, useMemo } from 'react'; import { User, UserRole, Announcement } from '../types'; import { Menu, X, Search, Bell, Sun, Moon, LogOut, ChevronRight, ChevronLeft, ChevronDown, - LayoutDashboard, BookOpen, UserCheck, Calendar, ShieldCheck, HelpCircle, Settings, Users, - School, GraduationCap, MapPin, BarChart3, FileText, ClipboardList, ShieldAlert, KeyRound, Clock + LayoutDashboard, BookOpen, HelpCircle, Settings, Users, ShieldCheck, + School, GraduationCap, MapPin, BarChart3, FileText, ClipboardList } from 'lucide-react'; +import { StudentSearch } from './StudentSearch'; interface NavigationItem { name: string; @@ -16,9 +17,12 @@ interface NavigationItem { interface LayoutProps { currentUser: User; + token: string; onRoleSwitch: (role: UserRole) => void; activeView: string; onSelectView: (view: string) => void; + onSelectPanel: (panel: string) => void; + onSelectStudent: (id: string) => void; notifications: Announcement[]; onMarkNotificationRead: (id: string) => void; onClearNotifications: () => void; @@ -28,9 +32,12 @@ interface LayoutProps { export const Layout: React.FC = ({ currentUser, + token, onRoleSwitch, activeView, onSelectView, + onSelectPanel, + onSelectStudent, notifications, onMarkNotificationRead, onClearNotifications, @@ -95,7 +102,7 @@ export const Layout: React.FC = ({ localStorage.setItem('fln_dark_mode', String(darkMode)); }, [darkMode]); - const collapsed = false; + const collapsed = sidebarCollapsed; const toggleSidebar = () => { setSidebarCollapsed((prev: boolean) => { @@ -139,7 +146,8 @@ export const Layout: React.FC = ({ subItems: [ { name: 'Student List', view: 'student_list' }, { name: 'Student Profile', view: 'student_profile' }, - { name: 'Performance', view: 'performance' } + { name: 'Performance', view: 'performance' }, + { name: 'Report Card', view: 'report_card' } ] }); list.push({ name: 'Worksheets', view: 'worksheets', icon: ClipboardList }); @@ -164,7 +172,8 @@ export const Layout: React.FC = ({ subItems: [ { name: 'Student List', view: 'student_list' }, { name: 'Student Profile', view: 'student_profile' }, - { name: 'Performance', view: 'performance' } + { name: 'Performance', view: 'performance' }, + { name: 'Report Card', view: 'report_card' } ] }); list.push({ name: 'Worksheets', view: 'worksheets', icon: ClipboardList }); @@ -339,6 +348,9 @@ export const Layout: React.FC = ({ MongoDB Connected + {/* Global Student Search */} + { onSelectStudent(id); onSelectPanel('student_profile'); }} /> + {/* Theme Toggle Button */} +
+ +
{/* Validation Alerts */} @@ -156,8 +190,9 @@ export const LoginView: React.FC = ({ onLoginSuccess, onBackToHo {mockUsersList.map(u => ( + + + {!forgotSent ? ( +
+

+ Enter your registered email address and we will send you a password reset link. +

+ setForgotEmail(e.target.value)} + className="w-full rounded-lg border-2 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-950 px-3.5 py-2.5 text-sm text-slate-950 dark:text-white placeholder-slate-400 focus:border-indigo-700 focus:outline-none focus:ring-1 focus:ring-indigo-700 font-medium" + placeholder="Enter your email address" + /> + +
+ ) : ( +
+
+ +
+

Reset link sent!

+

+ If an account exists with {forgotEmail}, you will receive a password reset link shortly. Check your inbox and spam folder. +

+ +
+ )} + + + )} ); }; \ No newline at end of file diff --git a/frontend/src/components/NotFoundView.tsx b/frontend/src/components/NotFoundView.tsx new file mode 100644 index 00000000..7fa57c3b --- /dev/null +++ b/frontend/src/components/NotFoundView.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Home, ArrowLeft } from 'lucide-react'; + +interface NotFoundViewProps { + onNavigateHome: () => void; +} + +export const NotFoundView: React.FC = ({ onNavigateHome }) => { + return ( +
+
+
+
+ 404 +
+
+ +

+ Page Not Found +

+

+ The page you are looking for does not exist or has been moved. + Please check the URL or return to the portal. +

+ +
+ + +
+ +
+ FLN Assessment Portal | NIPUN Bharat +
+
+
+ ); +}; diff --git a/frontend/src/components/PanelViews.tsx b/frontend/src/components/PanelViews.tsx index 6f04329e..bf1881ac 100644 --- a/frontend/src/components/PanelViews.tsx +++ b/frontend/src/components/PanelViews.tsx @@ -1,15 +1,20 @@ import { apiFetch } from '../services/apiClient'; import React, { useState, useEffect } from 'react'; -import { User, UserRole, Student, ClassGroup, School, EvaluationReport, LogEntry, Ticket } from '../types'; -import { Users, ShieldAlert, BookOpen, UserCheck, Calendar, ArrowRight, CheckCircle2, XCircle, SlidersHorizontal, Layers, Award, MapPin, School as SchoolIcon, BarChart3, FileText, ClipboardList, Building2, GraduationCap, BookMarked, Globe, Settings, Database, RefreshCw, Search, ChevronDown } from 'lucide-react'; +import { User, UserRole, Student, School, EvaluationReport } from '../types'; +import { Users, ShieldAlert, BookOpen, UserCheck, Calendar, CheckCircle2, XCircle, SlidersHorizontal, Award, MapPin, School as SchoolIcon, BarChart3, FileText, ClipboardList, GraduationCap, BookMarked, 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 { ReportCardView } from './ReportCardView'; +import { NotFoundView } from './NotFoundView'; +import { SystemHealthPanel } from './SystemHealthPanel'; interface PanelViewsProps { activePanel: string; currentUser: User; token: string; + onSelectPanel?: (panel: string) => void; + selectedStudentId?: string | null; } const STUDENTS_FALLBACK: Student[] = [ @@ -185,8 +190,7 @@ function EmptyStudents({ students }: { students: Student[] }) { return ; } -export const PanelViews: React.FC = ({ activePanel, currentUser, token }) => { - const [search, setSearch] = useState(''); +export const PanelViews: React.FC = ({ activePanel, currentUser, token, onSelectPanel, selectedStudentId }) => { const [stateFilter, setStateFilter] = useState('all'); const [distFilter, setDistFilter] = useState('all'); const [blockFilter, setBlockFilter] = useState('all'); @@ -203,6 +207,10 @@ export const PanelViews: React.FC = ({ activePanel, currentUser const [apiStudents, setApiStudents] = useState([]); const [apiSchools, setApiSchools] = useState([]); + + useEffect(() => { + if (selectedStudentId) setSel(selectedStudentId); + }, [selectedStudentId]); const [apiUsers, setApiUsers] = useState([]); useEffect(() => { @@ -438,6 +446,10 @@ export const PanelViews: React.FC = ({ activePanel, currentUser {s.name} + {showDropdown && ( <>
setShowDropdown(false)} /> @@ -875,6 +887,21 @@ export const PanelViews: React.FC = ({ activePanel, currentUser ); } + if (panel === 'report_card') { + const s = students.find(x => x.id === sel) ?? students[0]; + if (!s) return onSelectPanel?.('workspace')} />; + const reports = REPORTS_MOCK.filter(r => r.studentId === s.id); + const studentSchool = schools.find(sch => sch.id === s.schoolId); + return ( + onSelectPanel?.('student_profile')} + /> + ); + } + if (panel === 'diagnostic_test') { const pending = students.filter(s => s.levelHistory.length === 0); const completed = students.filter(s => s.levelHistory.length > 0); @@ -1493,6 +1520,10 @@ export const PanelViews: React.FC = ({ activePanel, currentUser ); } + if (panel === 'system_health') { + return ; + } + if (panel === 'system_settings') { return (
@@ -1527,6 +1558,6 @@ export const PanelViews: React.FC = ({ activePanel, currentUser ); } - // Fallback for any unmatched panel — renders the roles workspace (dashboard) as the content - return null; + // Fallback for any unmatched panel + return onSelectPanel?.('workspace')} />; }; diff --git a/frontend/src/components/ReportCardSkeleton.tsx b/frontend/src/components/ReportCardSkeleton.tsx new file mode 100644 index 00000000..a31815f7 --- /dev/null +++ b/frontend/src/components/ReportCardSkeleton.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +export const ReportCardSkeleton: React.FC = () => { + return ( +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ {[1, 2, 3, 4].map(i => ( +
+
+
+
+ ))} +
+ +
+
+
+
+ +
+
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+ ))} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/ReportCardView.tsx b/frontend/src/components/ReportCardView.tsx new file mode 100644 index 00000000..ecbc0f1e --- /dev/null +++ b/frontend/src/components/ReportCardView.tsx @@ -0,0 +1,446 @@ +import React from 'react'; +import { Student, EvaluationReport } from '../types'; +import { ArrowLeft, Printer, Download } from 'lucide-react'; + +interface ReportCardViewProps { + student: Student; + reports: EvaluationReport[]; + schoolName: string; + onBack: () => void; +} + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +const MASTERY_PRIORITY: Record = { 'Strong': 3, 'Satisfactory': 2, 'Needs Practice': 1 }; + +export const ReportCardView: React.FC = ({ student, reports, schoolName, onBack }) => { + const sortedReports = [...reports].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const latestReport = sortedReports.length > 0 ? sortedReports[0] : null; + const avgScore = reports.length > 0 + ? Math.round(reports.reduce((a, r) => a + (r.score / r.totalQuestions) * 100, 0) / reports.length) + : 0; + const allConceptMastery: Record = {}; + reports.forEach(r => { + Object.entries(r.conceptMastery).forEach(([topic, mastery]: [string, 'Strong' | 'Needs Practice' | 'Satisfactory']) => { + const current = allConceptMastery[topic]; + if (!current || (MASTERY_PRIORITY[mastery] ?? 0) > (MASTERY_PRIORITY[current] ?? 0)) { + allConceptMastery[topic] = mastery; + } + }); + }); + const certified = student.currentLevel >= 5; + const progressPct = Math.min(100, Math.round((student.currentLevel / 59) * 100)); + const scoreBand = avgScore >= 80 ? 'Strong' : avgScore >= 60 ? 'Satisfactory' : 'Needs Practice'; + + const handlePrint = () => { + const printWindow = window.open('', '_blank'); + if (!printWindow) { + alert('Please allow popups to print the report card.'); + return; + } + + const conceptRows = Object.entries(allConceptMastery).map(([topic, mastery]) => ` +
+ + + + `).join(''); + + const assessmentRows = reports.map(r => { + const pct = Math.round((r.score / r.totalQuestions) * 100); + return ` + + + + + + + `; + }).join(''); + + const levelHistoryRows = student.levelHistory.map(lh => ` + + + + + + `).join(''); + + const html = ` + + + + FLN Report Card - ${escapeHtml(student.name)} + + + + +
+
+
National Education Policy 2020
+
${escapeHtml(schoolName || 'Government Primary School')}
+
Foundational Literacy and Numeracy Assessment
+
Student Progress Report Card
+
+ +
+
+
${escapeHtml(student.name)}
+
${escapeHtml(student.classGroup)} - ${escapeHtml(student.section)} | ID: ${escapeHtml(student.id)} | Age: ${student.age} years
+
School ID: ${escapeHtml(student.schoolId)} | Aadhaar: ${escapeHtml(student.aadharMasked)}
+
+
+ ${certified ? 'FLN Certified' : 'In Progress'} +
+
+ +
+
+
L${student.currentLevel}.${student.currentSubLevel ?? 0}
+
Current FLN Level
+
+
+
${reports.length}
+
Assessments Taken
+
+
+
${avgScore}%
+
Average Score
+
+
+
${student.streak}
+
Day Streak
+
+
+ +
+
FLN Level Progress (Max: L59)
+
+ Level ${student.currentLevel} + Target: Level ${student.targetLevel} +
+
+
+
+
+ + ${reports.length > 0 ? ` +
Assessment History
+
${escapeHtml(topic)} + + ${escapeHtml(mastery)} + +
${escapeHtml(r.worksheetId)}${r.score}/${r.totalQuestions}${pct}%${new Date(r.timestamp).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
L${lh.level}.${lh.subLevel ?? 0}${escapeHtml(lh.reason)}${new Date(lh.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' })}
+ + + + + + + + + + ${assessmentRows} + +
WorksheetScorePercentageDate
+ ` : ''} + + ${Object.keys(allConceptMastery).length > 0 ? ` +
Skill Proficiency Breakdown
+ + + + + + + + + ${conceptRows} + +
Topic / SkillMastery Level
+ ` : ''} + + ${latestReport ? ` +
Teacher Evaluation Summary
+
${escapeHtml(latestReport.narrative)}
+ ` : ''} + + ${student.levelHistory.length > 0 ? ` +
Level Progression History
+ + + + + + + + + + ${levelHistoryRows} + +
Level AchievedAssessment TypeDate
+ ` : ''} + +
Overall Performance Band
+
+
+
${scoreBand}
+
Performance Band
+
+
+
L${student.currentLevel}
+
Current Level
+
+
+
L${student.targetLevel}
+
Target Level
+
+
+ +
+
+
Class Teacher Signature
+
+
+
Principal / Headmaster
+
+
+
Parent / Guardian
+
+
+ + + + + + + + `; + + printWindow.document.open(); + printWindow.document.write(html); + printWindow.document.close(); + }; + + return ( +
+
+ +
+ + +
+
+ +
+
+
+ National Education Policy 2020 +
+

Student Progress Report Card

+

Foundational Literacy and Numeracy Assessment

+
+ +
+
+
+

{student.name}

+

+ {student.classGroup} - {student.section} | ID: {student.id} | Age: {student.age} +

+

+ School: {schoolName} ({student.schoolId}) +

+
+
+ {certified ? 'FLN Certified' : 'In Progress'} +
+
+ +
+ {[ + { value: `L${student.currentLevel}.${student.currentSubLevel ?? 0}`, label: 'Current Level', color: 'text-indigo-600 dark:text-indigo-400' }, + { value: String(reports.length), label: 'Assessments', color: 'text-slate-900 dark:text-white' }, + { value: `${avgScore}%`, label: 'Average Score', color: avgScore >= 80 ? 'text-emerald-600' : avgScore >= 60 ? 'text-amber-600' : 'text-red-600' }, + { value: `${student.streak}`, label: 'Day Streak', color: student.streak >= 3 ? 'text-emerald-600' : 'text-amber-600' }, + ].map(m => ( +
+
{m.value}
+
{m.label}
+
+ ))} +
+ +
+

FLN Level Progress (Max: L59)

+
+ Level {student.currentLevel} + Target: Level {student.targetLevel} +
+
+
+
+
{progressPct}%
+
+ + {reports.length > 0 && ( +
+

Assessment History

+
+ + + + + + + + + + + {reports.map(r => { + const pct = Math.round((r.score / r.totalQuestions) * 100); + return ( + + + + + + + ); + })} + +
WorksheetScorePercentageDate
{r.worksheetId}{r.score}/{r.totalQuestions} + = 80 ? 'bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300' : pct >= 60 ? 'bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-300' : 'bg-red-100 dark:bg-red-950 text-red-700 dark:text-red-300'}`}>{pct}% + {new Date(r.timestamp).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
+
+
+ )} + + {Object.keys(allConceptMastery).length > 0 && ( +
+

Skill Proficiency Breakdown

+
+ {Object.entries(allConceptMastery).map(([topic, mastery]) => ( +
+ {topic} + {mastery} +
+ ))} +
+
+ )} + + {latestReport && ( +
+

Teacher Evaluation Summary

+
+ {latestReport.narrative} +
+
+ )} + + {student.levelHistory.length > 0 && ( +
+

Level Progression History

+
+ {student.levelHistory.map((lh, i) => ( +
+
+
+ L{lh.level} +
+
+
{lh.reason}
+
Level {lh.level}.{lh.subLevel ?? 0}
+
+
+ {new Date(lh.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })} +
+ ))} +
+
+ )} + +
+ {['Class Teacher', 'Principal / Headmaster', 'Parent / Guardian'].map(role => ( +
+
+ {role} +
+ ))} +
+ +
+

Confidential Student Academic Record

+

Generated by FLN Portal | NIPUN Bharat | {new Date().toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' })}

+
+
+
+
+ ); +}; diff --git a/frontend/src/components/SessionTimeout.tsx b/frontend/src/components/SessionTimeout.tsx new file mode 100644 index 00000000..dd3ad68f --- /dev/null +++ b/frontend/src/components/SessionTimeout.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Clock, LogOut, AlertTriangle } from 'lucide-react'; + +interface SessionTimeoutProps { + timeoutMinutes?: number; + warningMinutes?: number; + onLogout: () => void; +} + +export const SessionTimeout: React.FC = ({ + timeoutMinutes = 30, + warningMinutes = 5, + onLogout, +}) => { + const [showWarning, setShowWarning] = useState(false); + const [remainingSeconds, setRemainingSeconds] = useState(timeoutMinutes * 60); + const [lastActivity, setLastActivity] = useState(Date.now()); + + const resetTimer = useCallback(() => { + setLastActivity(Date.now()); + setRemainingSeconds(timeoutMinutes * 60); + setShowWarning(false); + }, [timeoutMinutes]); + + useEffect(() => { + const events = ['mousedown', 'keydown', 'scroll', 'touchstart']; + const handler = () => setLastActivity(Date.now()); + events.forEach(e => document.addEventListener(e, handler, { passive: true })); + return () => events.forEach(e => document.removeEventListener(e, handler)); + }, []); + + const logoutRef = React.useRef(onLogout); + logoutRef.current = onLogout; + + useEffect(() => { + const interval = setInterval(() => { + const elapsed = (Date.now() - lastActivity) / 1000; + const remaining = Math.max(0, timeoutMinutes * 60 - elapsed); + setRemainingSeconds(remaining); + + if (remaining <= 0) { + logoutRef.current(); + } else if (remaining <= warningMinutes * 60) { + setShowWarning(true); + } + }, 1000); + return () => clearInterval(interval); + }, [lastActivity, timeoutMinutes, warningMinutes]); + + if (!showWarning) return null; + + const minutes = Math.floor(remainingSeconds / 60); + const seconds = Math.floor(remainingSeconds % 60); + const isUrgent = remainingSeconds < 60; + + return ( +
+
+
+
+ {isUrgent ? ( + + ) : ( + + )} +
+ +

+ Session Expiring Soon +

+

+ Your session will expire due to inactivity. All unsaved changes may be lost. +

+ +
+ {minutes}:{seconds.toString().padStart(2, '0')} +
+

+ {isUrgent ? 'Session expires momentarily' : 'Time remaining'} +

+ +
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/components/StudentSearch.tsx b/frontend/src/components/StudentSearch.tsx new file mode 100644 index 00000000..86a7b172 --- /dev/null +++ b/frontend/src/components/StudentSearch.tsx @@ -0,0 +1,144 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Student, School } from '../types'; +import { Search, X, User, ArrowRight } from 'lucide-react'; + +interface StudentSearchProps { + token: string; + onSelectStudent: (studentId: string) => void; +} + +export const StudentSearch: React.FC = ({ token, onSelectStudent }) => { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [students, setStudents] = useState([]); + const [schools, setSchools] = useState([]); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + + useEffect(() => { + if (!token) return; + const headers = { Authorization: `Bearer ${token}` }; + fetch('/api/students', { headers }).then(r => r.json()).then(d => { if (Array.isArray(d)) setStudents(d); }).catch(() => {}); + fetch('/api/schools', { headers }).then(r => r.json()).then(d => { if (Array.isArray(d)) setSchools(d); }).catch(() => {}); + }, [token]); + + const filtered = query.trim().length >= 2 + ? students.filter(s => + s.name.toLowerCase().includes(query.toLowerCase()) || + s.id.toLowerCase().includes(query.toLowerCase()) || + s.classGroup.toLowerCase().includes(query.toLowerCase()) + ).slice(0, 8) + : []; + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === '/' && !isOpen && (document.activeElement as HTMLElement)?.tagName !== 'INPUT' && (document.activeElement as HTMLElement)?.tagName !== 'TEXTAREA') { + e.preventDefault(); + setIsOpen(true); + setTimeout(() => inputRef.current?.focus(), 50); + } + if (e.key === 'Escape' && isOpen) { + setIsOpen(false); + setQuery(''); + } + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setIsOpen(false); + setQuery(''); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const getSchoolName = (schoolId: string) => schools.find(s => s.id === schoolId)?.name || ''; + + return ( +
+ {!isOpen ? ( + + ) : ( +
+
+ + setQuery(e.target.value)} + placeholder="Type name, ID, or class..." + className="w-full rounded-lg border border-indigo-300 dark:border-indigo-600 bg-white dark:bg-slate-800 py-2 pl-9 pr-8 text-xs text-slate-900 dark:text-white outline-none ring-2 ring-indigo-100 dark:ring-indigo-900" + autoFocus + /> + {query && ( + + )} +
+ + {query.trim().length >= 2 && ( +
+ {filtered.length === 0 ? ( +
+

No students found matching "{query}"

+
+ ) : ( +
+ {filtered.map(s => { + const schoolName = getSchoolName(s.schoolId); + return ( + + ); + })} +
+ )} +
+ + {filtered.length} result{filtered.length !== 1 ? 's' : ''} · Press Esc to close + +
+
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/SystemHealthPanel.tsx b/frontend/src/components/SystemHealthPanel.tsx new file mode 100644 index 00000000..6f7ea144 --- /dev/null +++ b/frontend/src/components/SystemHealthPanel.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect } from 'react'; +import { Activity, Database, Users, School, Server, Clock, RefreshCw, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'; + +interface HealthData { + status: string; + timestamp: string; + uptime: number; +} + +interface StatsData { + totalStates: number; + totalDistricts: number; + totalSchools: number; + totalStudents: number; + totalUsers: number; + totalAssessments: number; + avgFlnLevel: number; + certifiedCount: number; + certifiedPercent: number; +} + +interface SystemHealthPanelProps { + token: string; +} + +export const SystemHealthPanel: React.FC = ({ token }) => { + const [health, setHealth] = useState(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [lastRefresh, setLastRefresh] = useState(new Date()); + const [apiLatency, setApiLatency] = useState(0); + + const fetchData = async () => { + setLoading(true); + const start = Date.now(); + try { + const [healthRes, statsRes] = await Promise.all([ + fetch('/api/health'), + fetch('/api/stats', { headers: { Authorization: `Bearer ${token}` } }), + ]); + const latency = Date.now() - start; + setApiLatency(latency); + if (healthRes.ok) setHealth(await healthRes.json()); + if (statsRes.ok) setStats(await statsRes.json()); + setLastRefresh(new Date()); + } catch { + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchData(); }, [token]); + useEffect(() => { + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, [token]); + + const formatUptime = (seconds: number) => { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; + }; + + const apiStatus = health?.status === 'ok' ? 'healthy' : 'down'; + const dbStatus = stats !== null ? 'connected' : 'unknown'; + const latencyStatus = apiLatency < 200 ? 'fast' : apiLatency < 1000 ? 'moderate' : 'slow'; + + const StatusBadge = ({ status }: { status: string }) => { + const colors = { + healthy: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400 dark:border-emerald-800', + connected: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400 dark:border-emerald-800', + fast: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400 dark:border-emerald-800', + moderate: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-400 dark:border-amber-800', + slow: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-400 dark:border-red-800', + down: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-400 dark:border-red-800', + unknown: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700', + }; + const icons = { + healthy: , + connected: , + fast: , + moderate: , + slow: , + down: , + unknown: , + }; + return ( + + {icons[status as keyof typeof icons]} + {status} + + ); + }; + + return ( +
+
+
+ +
+

System Health

+

Real-time platform monitoring dashboard

+
+
+ +
+ + {/* Service Status Grid */} +
+
+
+
+ + API Server +
+ +
+
+
Port3000
+
Uptime{health ? formatUptime(health.uptime) : '—'}
+
Latency{apiLatency}ms
+
+
+ +
+
+
+ + MongoDB +
+ +
+
+
Databasefln
+
Collections14
+
Replica SetSingle
+
+
+ +
+
+
+ + API Latency +
+ +
+
+
Current{apiLatency}ms
+
Threshold<200ms
+
Last Check{lastRefresh.toLocaleTimeString()}
+
+
+
+ + {/* Data Overview */} + {stats && ( +
+

Data Overview

+
+ {[ + { icon: School, value: stats.totalSchools.toLocaleString(), label: 'Schools', color: 'text-blue-600 dark:text-blue-400' }, + { icon: Users, value: stats.totalStudents.toLocaleString(), label: 'Students', color: 'text-indigo-600 dark:text-indigo-400' }, + { icon: Users, value: stats.totalUsers.toLocaleString(), label: 'Users', color: 'text-purple-600 dark:text-purple-400' }, + { icon: Activity, value: stats.totalAssessments.toLocaleString(), label: 'Assessments', color: 'text-amber-600 dark:text-amber-400' }, + { icon: CheckCircle2, value: `${stats.certifiedPercent}%`, label: 'Certified', color: 'text-emerald-600 dark:text-emerald-400' }, + ].map(m => ( +
+ +
{m.value}
+
{m.label}
+
+ ))} +
+
+
States covered{stats.totalStates}
+
Districts covered{stats.totalDistricts}
+
Avg FLN LevelL{stats.avgFlnLevel}
+
+
+ )} + +
+ Auto-refreshes every 30 seconds · Last updated {lastRefresh.toLocaleTimeString()} +
+
+ ); +};