feat: Round 24 - Klassenlehrer management, vice principal, exam calendar, student portal exams

- Klassenlehrer management: Assign/change responsible teacher, privilege badges
- Vice principal management: Assign/remove VP role, admins list
- Calendar exam integration: Exam countdown, print calendar, exam filter
- Student portal: Exams tab with countdown, AI study tips, virtual character
- Dashboard: Klassenlehrer section with pending illness/communications
- API: PUT /api/users/role for role management
- API: Enhanced classes GET to include responsibleTeacher
- 90+ new i18n keys
This commit is contained in:
Mustafa Cetin committed 2026-07-29 21:13:32 +00:00
1 parent 44ff170fca
commit f2caf267c7
12 files changed
+1522 -14

No files matched your search

+11
View File
@@ -23,6 +23,7 @@ import {
GraduationCap,
Filter,
Timer,
Printer,
} from 'lucide-react';
import {
format,
@@ -1116,6 +1117,16 @@ export default function CalendarView() {
<Filter className="h-4 w-4 mr-1.5" />
{showExamsOnly ? t('calendar.hide_exams') : t('calendar.show_exams')}
</Button>
{/* Print Calendar button */}
<Button
variant="outline"
size="sm"
onClick={() => window.print()}
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 min-h-[44px]"
>
<Printer className="h-4 w-4 mr-1.5" />
{t('calendar.print')}
</Button>
</div>
</motion.div>
+222 -2
View File
@@ -12,6 +12,7 @@ import {
QrCode, FileDown,
Shuffle, Printer, Eraser, Columns3, Rows3, Move,
Camera,
UserCheck, Shield, Eye, MessageSquare, CalendarDays, AlertCircle,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -19,9 +20,11 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
@@ -30,10 +33,10 @@ import {
fetchSubjects, fetchCompetencyTemplates, createClassCompetencyAssignment,
fetchClassCompetencyAssignments, downloadCsvExport, bulkCreateStudents,
fetchLearningProgress, reorderStudents,
apiGet, apiPut,
type LearningProgressEntry,
type ClassGroup, type Student, type Subject, type CompetencyTemplate, type ClassCompetencyAssignment,
} from '@/lib/api';
import { apiGet, apiPut } from '@/lib/api';
import { generateQRCodeSync, downloadQRCode, type QRCodeData } from '@/lib/qrcode';
import { toast } from 'sonner';
@@ -215,6 +218,12 @@ export default function ClassesView() {
const [classQrOpen, setClassQrOpen] = useState(false);
const [classQrDataUrl, setClassQrDataUrl] = useState<string>('');
// Klassenlehrer dialog
const [responsibleTeacherOpen, setResponsibleTeacherOpen] = useState(false);
const [selectedTeacherId, setSelectedTeacherId] = useState<string>('');
const [assigningTeacher, setAssigningTeacher] = useState(false);
const [teachers, setTeachers] = useState<Array<{ id: string; firstName: string; lastName: string; email: string }>>([]);
// Parse a CSV file (client-side). Supports quoted fields with escaped quotes.
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
@@ -490,6 +499,38 @@ export default function ClassesView() {
setEnrollOpen(true);
};
const openResponsibleTeacherDialog = async () => {
if (!currentUser?.schoolId) return;
try {
const resp = await apiGet<Array<{ id: string; firstName: string; lastName: string; email: string; role: string }>>(`/api/users?schoolId=${currentUser.schoolId}&role=TEACHER`);
setTeachers(resp);
setSelectedTeacherId(selectedClass?.responsibleTeacherId ?? '');
} catch {
// ignore
}
setResponsibleTeacherOpen(true);
};
const handleAssignResponsibleTeacher = async () => {
if (!selectedClass) return;
setAssigningTeacher(true);
try {
const teacherId = selectedTeacherId === 'none' ? null : (selectedTeacherId || null);
const updated = await apiPut<ClassGroup>(`/api/classes/${selectedClass.id}`, {
responsibleTeacherId: teacherId,
});
setSelectedClass(updated);
// Also update the class in the list
setClasses((prev) => prev.map((c) => c.id === updated.id ? updated : c));
toast.success(t('classes.teacher_assigned'));
setResponsibleTeacherOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setAssigningTeacher(false);
}
};
const openAssignDialog = async () => {
if (!currentUser?.schoolId) return;
try {
@@ -874,12 +915,18 @@ export default function ClassesView() {
<div className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
<Badge className={`${schoolTypeBadgeBg[cls.schoolType] ?? schoolTypeBadgeBg.OTHER} text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1`}>
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
</Badge>
{cls.responsibleTeacher && (
<Badge className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1 shadow-sm">
<UserCheck className="w-3 h-3" />
{cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName}
</Badge>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
@@ -1055,6 +1102,96 @@ export default function ClassesView() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Klassenlehrer Section */}
<div className="rounded-xl border border-emerald-200/40 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-500 text-white shadow-sm">
<UserCheck className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('classes.klassenlehrer')}</p>
<p className="text-[11px] text-emerald-600/70 dark:text-emerald-400/60">{t('classes.responsible_teacher')}</p>
</div>
</div>
{(currentUser?.role === 'SUPER_ADMIN' || currentUser?.role === 'SCHOOL_ADMIN') && (
<Button size="sm" variant="outline" className="border-emerald-300 dark:border-emerald-700 rounded-xl text-xs" onClick={openResponsibleTeacherDialog}>
<PenLine className="h-3 w-3 mr-1" />
{selectedClass.responsibleTeacher ? t('classes.change_teacher') : t('classes.assign_teacher')}
</Button>
)}
</div>
{selectedClass.responsibleTeacher ? (
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm text-sm font-bold">
{selectedClass.responsibleTeacher.firstName[0]}{selectedClass.responsibleTeacher.lastName[0]}
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{selectedClass.responsibleTeacher.firstName} {selectedClass.responsibleTeacher.lastName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{selectedClass.responsibleTeacher.email}</p>
</div>
<Badge className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white text-[10px] font-semibold rounded-md px-2 py-0.5 flex items-center gap-1 shadow-sm ml-2">
<Shield className="w-3 h-3" />
{t('classes.teacher_badge')}
</Badge>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<AlertCircle className="h-4 w-4 text-amber-500" />
<p className="text-sm">{t('classes.no_responsible_teacher')}</p>
</div>
)}
{selectedClass.responsibleTeacher && (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-emerald-100/60 dark:bg-emerald-900/20 border border-emerald-200/30 dark:border-emerald-800/20">
<Eye className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
<span className="text-[10px] text-emerald-700 dark:text-emerald-300 font-medium">{t('classes.illness_access')}</span>
</div>
</TooltipTrigger>
<TooltipContent>{t('classes.illness_access')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-teal-100/60 dark:bg-teal-900/20 border border-teal-200/30 dark:border-teal-800/20">
<MessageSquare className="h-3 w-3 text-teal-600 dark:text-teal-400" />
<span className="text-[10px] text-teal-700 dark:text-teal-300 font-medium">{t('classes.communication_access')}</span>
</div>
</TooltipTrigger>
<TooltipContent>{t('classes.communication_access')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-100/60 dark:bg-amber-900/20 border border-amber-200/30 dark:border-amber-800/20">
<CalendarDays className="h-3 w-3 text-amber-600 dark:text-amber-400" />
<span className="text-[10px] text-amber-700 dark:text-amber-300 font-medium">{t('classes.counseling_access')}</span>
</div>
</TooltipTrigger>
<TooltipContent>{t('classes.counseling_access')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-rose-100/60 dark:bg-rose-900/20 border border-rose-200/30 dark:border-rose-800/20">
<AlertCircle className="h-3 w-3 text-rose-600 dark:text-rose-400" />
<span className="text-[10px] text-rose-700 dark:text-rose-300 font-medium">{t('classes.disciplinary_access')}</span>
</div>
</TooltipTrigger>
<TooltipContent>{t('classes.disciplinary_access')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
{/* Teachers */}
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">{t('classes.teacher_list')}</p>
@@ -1095,6 +1232,9 @@ export default function ClassesView() {
<div>
<p className="text-[10px] uppercase tracking-wider text-emerald-600/70 dark:text-emerald-400/60 font-semibold">{t('polish.total_students')}</p>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{classStats.total}</p>
<div className="w-16 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 mt-1 overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400" style={{ width: `${Math.min(100, (classStats.total / 30) * 100)}%` }} />
</div>
</div>
</div>
<div className="flex items-center gap-2.5">
@@ -1115,6 +1255,17 @@ export default function ClassesView() {
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">Ø {classStats.avgEntries.toFixed(1)}</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<FileText className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-violet-600/70 dark:text-violet-400/60 font-semibold">{t('classes.active_assessments')}</p>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{selectedClass.assessmentCount ?? selectedClass._count?.assessments ?? 0}</p>
</div>
</div>
</div>
<div className="px-4 pb-4">
<div className={`flex items-center gap-2.5 rounded-lg px-3 py-2 border ${healthMeta[classStats.health].bg}`}>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-white/70 dark:bg-gray-800/60 ${healthMeta[classStats.health].text} shrink-0`}>
<HeartPulse className="h-4 w-4" />
@@ -2024,6 +2175,75 @@ export default function ClassesView() {
</div>
</DialogContent>
</Dialog>
{/* Klassenlehrer Assign/Change Dialog */}
<Dialog open={responsibleTeacherOpen} onOpenChange={setResponsibleTeacherOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCheck className="h-5 w-5 text-emerald-600" />
{selectedClass?.responsibleTeacher ? t('classes.change_teacher') : t('classes.assign_teacher')}
</DialogTitle>
<DialogDescription>{t('classes.privileges_info')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-2 block">{t('classes.select_teacher')}</Label>
<Select value={selectedTeacherId} onValueChange={setSelectedTeacherId}>
<SelectTrigger className="rounded-xl">
<SelectValue placeholder={t('classes.select_teacher')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('classes.no_responsible_teacher')}</SelectItem>
{teachers.map((teacher) => (
<SelectItem key={teacher.id} value={teacher.id}>
{teacher.firstName} {teacher.lastName} ({teacher.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTeacherId && selectedTeacherId !== 'none' && (
<div className="rounded-xl border border-emerald-200/40 dark:border-emerald-900/30 bg-emerald-50/50 dark:bg-emerald-900/10 p-3">
<p className="text-xs font-medium text-emerald-700 dark:text-emerald-300 mb-2">{t('classes.teacher_privileges')}</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Eye className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs text-gray-700 dark:text-gray-300">{t('classes.illness_access')}</span>
</div>
<div className="flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-teal-500" />
<span className="text-xs text-gray-700 dark:text-gray-300">{t('classes.communication_access')}</span>
</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-3.5 w-3.5 text-amber-500" />
<span className="text-xs text-gray-700 dark:text-gray-300">{t('classes.counseling_access')}</span>
</div>
<div className="flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5 text-rose-500" />
<span className="text-xs text-gray-700 dark:text-gray-300">{t('classes.disciplinary_access')}</span>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResponsibleTeacherOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
<Button
onClick={handleAssignResponsibleTeacher}
disabled={assigningTeacher}
className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white rounded-xl"
>
{assigningTeacher ? (
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2" />
) : (
<UserCheck className="h-4 w-4 mr-2" />
)}
{t('action.assign')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+207
View File
@@ -1032,6 +1032,193 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
);
}
// ── Klassenlehrer Dashboard Sub-Component ─────────────────────────────────
function KlassenlehrerDashboard({ schoolId, userId }: { schoolId: string; userId: string }) {
const [responsibleClasses, setResponsibleClasses] = useState<Array<{
id: string;
name: string;
gradeLevel: number;
studentCount: number;
responsibleTeacherId: string | null;
}>>([]);
const [pendingIllness, setPendingIllness] = useState<Array<{
id: string;
student: { firstName: string; lastName: string };
reason: string;
startDate: string;
status: string;
}>>([]);
const [upcomingExams, setUpcomingExams] = useState<Array<{
id: string;
title: string;
date: string;
subject: { name: string } | null;
classGroup: { name: string } | null;
daysUntil: number;
}>>([]);
const [loading, setLoading] = useState(true);
const locale = useAppStore((s) => s.locale);
const setCurrentView = useAppStore((s) => s.setCurrentView);
useEffect(() => {
if (!schoolId) return;
setLoading(true);
Promise.all([
fetch(`/api/classes?schoolId=${schoolId}`).then(r => r.json()).catch(() => []),
fetch(`/api/illness-reports?schoolId=${schoolId}&status=pending`).then(r => r.json()).catch(() => []),
fetch(`/api/calendar-events/exams?schoolId=${schoolId}&limit=5`).then(r => r.json()).catch(() => []),
]).then(([classesData, illnessData, examsData]) => {
const allClasses = Array.isArray(classesData) ? classesData : [];
// Filter to classes where the current user is the responsible teacher
const myClasses = allClasses.filter((c: Record<string, unknown>) =>
c.responsibleTeacherId === userId ||
(c.responsibleTeacher && (c.responsibleTeacher as Record<string, unknown>).id === userId)
);
setResponsibleClasses(myClasses.map((c: Record<string, unknown>) => ({
id: c.id as string,
name: c.name as string,
gradeLevel: c.gradeLevel as number,
studentCount: (c.studentCount ?? c._count?.enrollments ?? 0) as number,
responsibleTeacherId: c.responsibleTeacherId as string | null,
})));
setPendingIllness(Array.isArray(illnessData) ? illnessData.slice(0, 5) : []);
setUpcomingExams(Array.isArray(examsData) ? examsData.slice(0, 5) : []);
}).catch(() => {
// ignore
}).finally(() => {
setLoading(false);
});
}, [schoolId, userId]);
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50/60 dark:bg-gray-800/40">
<div className="h-9 w-9 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="h-2.5 w-2/3 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{/* My Classes */}
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-emerald-500" />
{t('dashboard.my_classes')}
</p>
{responsibleClasses.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">{locale === 'de' ? 'Keine Klassen als Klassenlehrer zugewiesen' : 'No classes assigned as homeroom teacher'}</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{responsibleClasses.map((cls) => (
<motion.div
key={cls.id}
whileHover={{ scale: 1.02 }}
className="p-3 rounded-xl bg-gradient-to-r from-emerald-50/40 to-transparent dark:from-emerald-900/10 dark:to-transparent border border-emerald-100/40 dark:border-emerald-900/20 cursor-pointer"
onClick={() => setCurrentView('classes')}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('label.grade')} {cls.gradeLevel}</p>
</div>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs">
{cls.studentCount} <Users className="h-3 w-3 ml-1" />
</Badge>
</div>
</motion.div>
))}
</div>
)}
</div>
{/* Pending Illness Reports */}
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<Heart className="h-4 w-4 text-amber-500" />
{t('dashboard.pending_illness')}
</p>
{pendingIllness.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">{t('dashboard.no_illness_reports')}</p>
) : (
<div className="space-y-2">
{pendingIllness.map((report: Record<string, unknown>) => (
<div key={report.id as string} className="flex items-center justify-between p-2 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-100/40 dark:border-amber-900/20">
<div className="flex items-center gap-2">
<Heart className="h-3.5 w-3.5 text-amber-500" />
<span className="text-xs font-medium text-gray-900 dark:text-gray-100">
{(report.student as Record<string, string>)?.firstName} {(report.student as Record<string, string>)?.lastName}
</span>
</div>
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-[10px]">
{report.reason as string}
</Badge>
</div>
))}
</div>
)}
</div>
{/* Upcoming Exams */}
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-red-500" />
{t('dashboard.upcoming_exams')}
</p>
{upcomingExams.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">{t('calendar.no_exams')}</p>
) : (
<div className="space-y-2">
{upcomingExams.map((exam: Record<string, unknown>) => {
const daysUntil = exam.daysUntil as number;
const urgencyClass = daysUntil <= 1
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
: daysUntil <= 3
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
return (
<div key={exam.id as string} className="flex items-center justify-between p-2 rounded-lg bg-red-50/50 dark:bg-red-900/10 border border-red-100/40 dark:border-red-900/20">
<div className="flex items-center gap-2">
<GraduationCap className="h-3.5 w-3.5 text-red-500" />
<span className="text-xs font-medium text-gray-900 dark:text-gray-100">{exam.title as string}</span>
</div>
<Badge className={`${urgencyClass} text-[10px]`}>
{daysUntil === 0 ? t('calendar.exam_today') : daysUntil === 1 ? t('calendar.exam_tomorrow') : t('calendar.exam_in_days', { days: daysUntil })}
</Badge>
</div>
);
})}
</div>
)}
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" className="rounded-xl text-xs" onClick={() => setCurrentView('illness')}>
<Heart className="h-3 w-3 mr-1" />
{t('dashboard.pending_illness')}
</Button>
<Button size="sm" variant="outline" className="rounded-xl text-xs" onClick={() => setCurrentView('communication')}>
<MessageSquare className="h-3 w-3 mr-1" />
{t('dashboard.pending_communications')}
</Button>
<Button size="sm" variant="outline" className="rounded-xl text-xs" onClick={() => setCurrentView('calendar')}>
<CalendarDays className="h-3 w-3 mr-1" />
{t('dashboard.upcoming_exams')}
</Button>
</div>
</div>
);
}
export default function DashboardView() {
const currentUser = useAppStore((s) => s.currentUser);
const setCurrentView = useAppStore((s) => s.setCurrentView);
@@ -2246,6 +2433,26 @@ export default function DashboardView() {
</Card>
</motion.div>
{/* ===== KLASSENLEHRER SECTION (for teachers) ===== */}
{(currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL' || currentUser?.role === 'SUPER_ADMIN') && (
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<UserCheck className="h-4 w-4" />
</div>
{t('dashboard.klassenlehrer_section')}
<div className="h-0.5 w-16 rounded-full bg-gradient-to-r from-emerald-400 to-transparent" />
</CardTitle>
</CardHeader>
<CardContent>
<KlassenlehrerDashboard schoolId={currentUser?.schoolId ?? ''} userId={currentUser?.id ?? ''} />
</CardContent>
</Card>
</motion.div>
)}
{/* ===== QUICK ACTION BUTTONS ===== */}
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row flex-wrap gap-3">
<motion.div whileHover={{ scale: 1.02, y: -1 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.15 }}>
+205 -10
View File
@@ -1304,6 +1304,210 @@ function PrivacyTab({ currentUser }: { currentUser: { id: string; role: string;
);
}
// ── Vice Principal Manager Sub-Component ────────────────────────────────
function VicePrincipalManager({ schoolId }: { schoolId: string }) {
const [users, setUsers] = useState<Array<{ id: string; firstName: string; lastName: string; email: string; role: string; schoolId: string | null }>>([]);
const [loading, setLoading] = useState(true);
const [changingRole, setChangingRole] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{ userId: string; newRole: string; userName: string } | null>(null);
const locale = useAppStore((s) => s.locale);
const loadUsers = useCallback(async () => {
if (!schoolId) return;
setLoading(true);
try {
const data = await apiGet<Array<{ id: string; firstName: string; lastName: string; email: string; role: string; schoolId: string | null }>>(`/api/users?schoolId=${schoolId}`);
setUsers(data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [schoolId]);
useEffect(() => { loadUsers(); }, [loadUsers]);
const vicePrincipals = users.filter((u) => u.role === 'VICE_PRINCIPAL');
const admins = users.filter((u) => u.role === 'SCHOOL_ADMIN' || u.role === 'SUPER_ADMIN' || u.role === 'VICE_PRINCIPAL');
const teachers = users.filter((u) => u.role === 'TEACHER');
const handleRoleChange = async (userId: string, newRole: string) => {
setChangingRole(userId);
try {
await apiPut('/api/users/role', { userId, role: newRole });
await loadUsers();
toast.success(t('settings.role_changed'));
setConfirmDialog(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('error.generic'));
} finally {
setChangingRole(null);
}
};
const roleLabel = (role: string) => {
switch (role) {
case 'SUPER_ADMIN': return 'Super Admin';
case 'SCHOOL_ADMIN': return locale === 'de' ? 'Schulleiter' : 'School Admin';
case 'VICE_PRINCIPAL': return t('settings.vice_principal_short');
case 'TEACHER': return locale === 'de' ? 'Lehrer' : 'Teacher';
default: return role;
}
};
const roleBadgeClass = (role: string) => {
switch (role) {
case 'SUPER_ADMIN': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
case 'SCHOOL_ADMIN': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
case 'VICE_PRINCIPAL': return 'bg-gradient-to-r from-teal-500 to-teal-600 text-white';
case 'TEACHER': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300';
}
};
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50/60 dark:bg-gray-800/40">
<div className="h-9 w-9 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="h-2.5 w-2/3 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
</div>
))}
</div>
);
}
return (
<div className="space-y-6">
{/* Current Vice Principals */}
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<Shield className="h-4 w-4 text-teal-500" />
{t('settings.current_admins')}
</p>
{admins.length === 0 ? (
<div className="text-center py-6">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-teal-100 dark:bg-teal-900/30 mx-auto mb-3">
<Shield className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{locale === 'de' ? 'Keine Administratoren gefunden' : 'No administrators found'}</p>
</div>
) : (
<div className="space-y-2">
{admins.map((admin) => (
<motion.div
key={admin.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-teal-50/40 to-transparent dark:from-teal-900/10 dark:to-transparent border border-teal-100/40 dark:border-teal-900/20"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-full bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm text-sm font-bold">
{admin.firstName[0]}{admin.lastName[0]}
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{admin.firstName} {admin.lastName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{admin.email}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={`${roleBadgeClass(admin.role)} text-xs font-medium rounded-md px-2 py-0.5`}>
{roleLabel(admin.role)}
</Badge>
{admin.role === 'VICE_PRINCIPAL' && (
<Button
size="sm"
variant="outline"
className="border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400 rounded-xl text-xs h-7"
disabled={changingRole === admin.id}
onClick={() => setConfirmDialog({ userId: admin.id, newRole: 'TEACHER', userName: `${admin.firstName} ${admin.lastName}` })}
>
{changingRole === admin.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3 mr-1" />}
{t('settings.remove_vice_principal')}
</Button>
)}
</div>
</motion.div>
))}
</div>
)}
</div>
{/* Assign Vice Principal */}
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<UserPlus className="h-4 w-4 text-emerald-500" />
{t('settings.assign_vice_principal')}
</p>
{teachers.length === 0 ? (
<div className="text-center py-4">
<p className="text-sm text-gray-500 dark:text-gray-400">{locale === 'de' ? 'Keine Lehrer verfuegbar' : 'No teachers available'}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education">
{teachers.map((teacher) => (
<div
key={teacher.id}
className="flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-emerald-50/30 to-transparent dark:from-emerald-900/5 dark:to-transparent border border-emerald-100/30 dark:border-emerald-900/10"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 text-xs font-bold">
{teacher.firstName[0]}{teacher.lastName[0]}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{teacher.firstName} {teacher.lastName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{teacher.email}</p>
</div>
</div>
<Button
size="sm"
className="rounded-xl bg-gradient-to-r from-teal-500 to-teal-600 text-white shadow-md text-xs h-7"
disabled={changingRole === teacher.id}
onClick={() => setConfirmDialog({ userId: teacher.id, newRole: 'VICE_PRINCIPAL', userName: `${teacher.firstName} ${teacher.lastName}` })}
>
{changingRole === teacher.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3 mr-1" />}
{t('settings.assign_vice_principal')}
</Button>
</div>
))}
</div>
)}
</div>
{/* Confirm Dialog */}
<Dialog open={!!confirmDialog} onOpenChange={(open) => !open && setConfirmDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
{t('settings.confirm_role_change')}
</DialogTitle>
<DialogDescription>
{confirmDialog?.newRole === 'VICE_PRINCIPAL'
? t('settings.assign_vice_principal')
: t('settings.confirm_role_remove')
} — {confirmDialog?.userName}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialog(null)} className="rounded-xl">{t('action.cancel')}</Button>
<Button
onClick={() => confirmDialog && handleRoleChange(confirmDialog.userId, confirmDialog.newRole)}
className="rounded-xl bg-gradient-to-r from-teal-500 to-teal-600 text-white"
>
{t('action.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default function SettingsView() {
const currentUser = useAppStore((s) => s.currentUser);
const [activeTab, setActiveTab] = useState('school');
@@ -5319,16 +5523,7 @@ export default function SettingsView() {
<CardDescription>{t('settings.vice_principal_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-6">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-teal-100 dark:bg-teal-900/30 mx-auto mb-3">
<Shield className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">{locale === 'de' ? 'Keine Stellvertretenden Schulleiter zugewiesen' : 'No vice principals assigned'}</p>
<Button className="rounded-xl bg-gradient-to-r from-teal-500 to-teal-600 text-white shadow-md" onClick={() => toast.info(locale === 'de' ? 'Funktion wird bald verfuegbar sein' : 'Feature coming soon')}>
<UserPlus className="h-4 w-4 mr-1" />
{t('action.add')}
</Button>
</div>
<VicePrincipalManager schoolId={currentUser?.schoolId ?? ''} />
</CardContent>
</Card>
+213
View File
@@ -263,6 +263,113 @@ function CelebrationParticles({ active }: { active: boolean }) {
);
}
// ── Student Exam Section Sub-Component ──────────────────────────────────
function StudentExamSection({ schoolId }: { schoolId: string }) {
const [exams, setExams] = useState<Array<{
id: string;
title: string;
date: string;
startTime: string | null;
endTime: string | null;
subject: { id: string; name: string } | null;
classGroup: { id: string; name: string } | null;
teacher: { id: string; firstName: string; lastName: string } | null;
daysUntil: number;
countdownLabel: string;
}>>([]);
const [loading, setLoading] = useState(true);
const locale = useAppStore((s) => s.locale);
useEffect(() => {
if (!schoolId) return;
setLoading(true);
fetch(`/api/calendar-events/exams?schoolId=${schoolId}&limit=10`)
.then((res) => res.json())
.then((data) => {
setExams(Array.isArray(data) ? data : []);
})
.catch(() => {
// ignore
})
.finally(() => {
setLoading(false);
});
}, [schoolId]);
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50/60 dark:bg-gray-800/40">
<div className="h-9 w-9 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="h-2.5 w-2/3 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
</div>
))}
</div>
);
}
if (exams.length === 0) {
return (
<div className="text-center py-6">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-red-100 dark:bg-red-900/30 mx-auto mb-3">
<CalendarDays className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('portal.no_exams_scheduled')}</p>
</div>
);
}
return (
<div className="space-y-2">
{exams.map((exam, idx) => {
const daysUntil = exam.daysUntil;
const countdownText = daysUntil === 0
? t('calendar.exam_today')
: daysUntil === 1
? t('calendar.exam_tomorrow')
: daysUntil < 7
? t('calendar.exam_in_days', { days: daysUntil })
: t('calendar.exam_in_weeks', { weeks: Math.floor(daysUntil / 7) });
const urgencyClass = daysUntil <= 1
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200/60 dark:border-red-900/30'
: daysUntil <= 3
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-amber-200/60 dark:border-amber-900/30'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-900/30';
return (
<motion.div
key={exam.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
className="flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-red-50/30 to-transparent dark:from-red-900/5 dark:to-transparent border border-red-100/30 dark:border-red-900/10"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-red-400 to-red-500 text-white shadow-sm text-sm font-bold">
{exam.subject?.name?.substring(0, 2)?.toUpperCase() ?? 'EX'}
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{exam.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{exam.subject?.name ?? ''} {exam.classGroup?.name ? `· ${exam.classGroup.name}` : ''}
</p>
</div>
</div>
<Badge className={`${urgencyClass} text-[10px] font-semibold rounded-md px-2 py-0.5 flex items-center gap-1 shrink-0`}>
<Clock className="h-3 w-3" />
{countdownText}
</Badge>
</motion.div>
);
})}
</div>
);
}
// ── Component ───────────────────────────────────────────────────────────
export default function StudentPortalView() {
@@ -664,6 +771,11 @@ export default function StudentPortalView() {
<span className="hidden sm:inline">{t('student_portal.my_feedback')}</span>
<span className="sm:hidden">{t('student_portal.my_feedback').split(' ')[0]}</span>
</TabsTrigger>
<TabsTrigger value="exams" className="text-xs sm:text-sm gap-1.5 min-h-[40px] data-[state=active]:bg-white dark:data-[state=active]:bg-gray-900 data-[state=active]:shadow-sm rounded-lg">
<CalendarDays className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('portal.upcoming_exams')}</span>
<span className="sm:hidden">{t('portal.upcoming_exams').split(' ')[0]}</span>
</TabsTrigger>
</TabsList>
{/* ── My Competencies Tab ──────────────────────────────────── */}
@@ -1386,6 +1498,107 @@ export default function StudentPortalView() {
</motion.div>
</AnimatePresence>
</TabsContent>
{/* ── Exams & Study Tips Tab ─────────────────────────────────── */}
<TabsContent value="exams">
<AnimatePresence mode="wait">
<motion.div
key="exams"
variants={tabContentVariants}
initial="hidden"
animate="visible"
exit="exit"
className="space-y-6"
>
{/* Upcoming Exams with Countdown */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-red-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-red-50/50 to-transparent dark:from-red-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-red-400 to-red-500 text-white shadow-sm">
<CalendarDays className="h-4 w-4" />
</div>
{t('portal.upcoming_exams')}
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-red-400 to-transparent" />
</CardTitle>
</CardHeader>
<CardContent>
<StudentExamSection schoolId={currentUser?.schoolId ?? ''} />
</CardContent>
</Card>
{/* AI Study Tips */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<Sparkles className="h-4 w-4" />
</div>
{t('portal.study_tips')}
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-emerald-400 to-transparent" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ title: t('portal.focus_areas'), desc: t('portal.motivation_message'), icon: Target, color: 'from-emerald-400 to-teal-500' },
{ title: t('portal.practice_recommendations'), desc: t('portal.start_practice'), icon: BookOpen, color: 'from-teal-400 to-emerald-500' },
{ title: t('portal.ai_study_tip'), desc: t('portal.motivation_message'), icon: Sparkles, color: 'from-amber-400 to-amber-500' },
].map((tip, idx) => {
const Icon = tip.icon;
return (
<motion.div
key={idx}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: idx * 0.1 }}
className="flex items-start gap-3 p-3 rounded-xl bg-gradient-to-r from-emerald-50/30 to-transparent dark:from-emerald-900/5 dark:to-transparent border border-emerald-100/30 dark:border-emerald-900/10"
>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br ${tip.color} text-white shadow-sm shrink-0 mt-0.5`}>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{tip.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{tip.desc}</p>
</div>
</motion.div>
);
})}
</div>
</CardContent>
</Card>
{/* Virtual Character / AI Assistant */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<Sparkles className="h-4 w-4" />
</div>
{t('portal.ai_assistant')}
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-violet-400 to-transparent" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-r from-violet-50/50 to-teal-50/30 dark:from-violet-900/10 dark:to-teal-900/5 border border-violet-100/40 dark:border-violet-900/20">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-violet-400 to-teal-500 text-white shadow-lg">
<Sparkles className="h-6 w-6" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('portal.virtual_character')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('portal.motivation_message')}</p>
</div>
<Button size="sm" className="rounded-xl bg-gradient-to-r from-violet-500 to-teal-500 text-white shadow-md" onClick={() => {
const store = useAppStore.getState();
store.setCurrentView('subjects');
}}>
{t('portal.ask_ai')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</AnimatePresence>
</TabsContent>
</Tabs>
</motion.div>