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

+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>