Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 186 additions & 74 deletions backend/src/db.ts

Large diffs are not rendered by default.

37 changes: 23 additions & 14 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/paperGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 21 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -32,6 +35,10 @@ export default function App() {
const [activePanel, setActivePanel] = useState<string>('workspace');
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [toast, setToast] = useState<string | null>(null);
const [showShortcuts, setShowShortcuts] = useState(false);
const [selectedStudentId, setSelectedStudentId] = useState<string | null>(null);

useKeyboardShortcuts(() => setShowShortcuts(p => !p));

const triggerToast = (msg: string) => {
setToast(msg);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -129,9 +136,12 @@ export default function App() {
{currentView === 'dashboard' && currentUser && token && (
<Layout
currentUser={currentUser}
token={token}
onRoleSwitch={handleRoleSwitch}
activeView={activePanel}
onSelectView={setActivePanel}
onSelectPanel={setActivePanel}
onSelectStudent={setSelectedStudentId}
notifications={announcements}
onMarkNotificationRead={handleMarkNotificationRead}
onClearNotifications={handleClearNotifications}
Expand Down Expand Up @@ -225,7 +235,9 @@ export default function App() {
)}

{!['workspace', 'logbook', 'tickets', 'calendar', 'settings', 'notifications'].includes(activePanel) && (
<PanelViews activePanel={activePanel} currentUser={currentUser} token={token} />
<ErrorBoundary fallbackTitle="Panel Error">
<PanelViews activePanel={activePanel} currentUser={currentUser} token={token} onSelectPanel={setActivePanel} selectedStudentId={selectedStudentId} />
</ErrorBoundary>
)}

{toast && (
Expand All @@ -238,6 +250,11 @@ export default function App() {
)}
</Layout>
)}

{currentView === 'dashboard' && currentUser && token && (
<SessionTimeout timeoutMinutes={30} warningMinutes={5} onLogout={handleLogout} />
)}
<KeyboardShortcuts visible={showShortcuts} onClose={() => setShowShortcuts(false)} />
</div>
}
/>
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<ErrorBoundaryProps> = ({ children, fallbackTitle }) => {
const [error, setError] = useState<Error | null>(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 (
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-xl border border-red-200 dark:border-red-900 bg-red-50/50 dark:bg-red-950/30 p-8 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50">
<AlertTriangle className="h-8 w-8 text-red-500 dark:text-red-400" />
</div>
<h3 className="text-lg font-bold text-red-800 dark:text-red-200">
{fallbackTitle || 'Something went wrong'}
</h3>
<p className="mt-2 max-w-md text-sm text-red-600 dark:text-red-300">
An unexpected error occurred while rendering this section.
You can try again or navigate to a different page.
</p>
{error.message && (
<p className="mt-3 max-w-lg rounded-lg bg-red-100/80 dark:bg-red-900/40 px-4 py-2 font-mono text-[11px] text-red-700 dark:text-red-300">
{error.message}
</p>
)}
<button
onClick={() => setError(null)}
className="mt-6 flex items-center gap-2 rounded-lg bg-red-600 px-5 py-2.5 text-xs font-bold text-white shadow-sm transition-colors hover:bg-red-700"
>
<RefreshCw className="h-3.5 w-3.5" />
Try Again
</button>
</div>
);
}

return <>{children}</>;
};
105 changes: 105 additions & 0 deletions frontend/src/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -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<KeyboardShortcutsProps> = ({ 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div
className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<Keyboard className="h-5 w-5 text-indigo-500" />
<h2 className="text-lg font-bold text-slate-900 dark:text-white">Keyboard Shortcuts</h2>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="h-4 w-4" />
</button>
</div>

<div className="space-y-5">
{SHORTCUTS.map(group => (
<div key={group.category}>
<h3 className="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest mb-2">
{group.category}
</h3>
<div className="space-y-1.5">
{group.items.map(item => (
<div key={item.description} className="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<span className="text-sm text-slate-700 dark:text-slate-300">{item.description}</span>
<div className="flex items-center gap-1">
{item.keys.map((key, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="text-[10px] text-slate-300 dark:text-slate-600">+</span>}
<kbd className="inline-flex h-6 min-w-[24px] items-center justify-center rounded border border-slate-200 bg-slate-50 px-1.5 font-mono text-[11px] font-medium text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
{key}
</kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>

<div className="mt-5 text-center text-[10px] text-slate-400 dark:text-slate-500">
Press <kbd className="rounded border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 px-1 py-0.5 font-mono">?</kbd> or <kbd className="rounded border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 px-1 py-0.5 font-mono">Esc</kbd> to close
</div>
</div>
</div>
);
};

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]);
};
22 changes: 17 additions & 5 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -28,9 +32,12 @@ interface LayoutProps {

export const Layout: React.FC<LayoutProps> = ({
currentUser,
token,
onRoleSwitch,
activeView,
onSelectView,
onSelectPanel,
onSelectStudent,
notifications,
onMarkNotificationRead,
onClearNotifications,
Expand Down Expand Up @@ -95,7 +102,7 @@ export const Layout: React.FC<LayoutProps> = ({
localStorage.setItem('fln_dark_mode', String(darkMode));
}, [darkMode]);

const collapsed = false;
const collapsed = sidebarCollapsed;

const toggleSidebar = () => {
setSidebarCollapsed((prev: boolean) => {
Expand Down Expand Up @@ -139,7 +146,8 @@ export const Layout: React.FC<LayoutProps> = ({
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 });
Expand All @@ -164,7 +172,8 @@ export const Layout: React.FC<LayoutProps> = ({
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 });
Expand Down Expand Up @@ -339,6 +348,9 @@ export const Layout: React.FC<LayoutProps> = ({
<span className="text-emerald-700 text-[10px] uppercase tracking-wider">MongoDB Connected</span>
</div>

{/* Global Student Search */}
<StudentSearch token={token} onSelectStudent={(id) => { onSelectStudent(id); onSelectPanel('student_profile'); }} />

{/* Theme Toggle Button */}
<button
onClick={() => setDarkMode(!darkMode)}
Expand Down
Loading