Round 19: Professional Notebooks, Digital Rewards, GDPR, Role-Based Dashboard, Deep Styling Polish

- Fixed attendance API 403: added TEACHER school membership check
- Fixed competitions API 403: cross-school access, null schoolId, public fallback
- Fixed GDPR export API 500: 16 Prisma field name mismatches
- Enhanced notebooks: page templates (Cornell, Mind Map, Venn, T-Chart, Weekly Planner),
  sticky notes, search, page duplication, sections/chapters, highlighter tool
- Added scratch pad with DrawingCanvas in assessments for hand-written solutions
- Role-based dashboard: TEACHER (competence radar, upcoming lessons),
  STUDENT (grades trend, competitions), PARENT (children grades, calendar),
  SCHOOL_ADMIN (system health, audit log, user management)
- Digital reward system: 16 demo rewards, points earning/redemption,
  categories (streaming, shopping, experiences, merchandise, privileges)
- GDPR compliance: consent management, data retention, cookie consent
- Deep styling polish: glassmorphism, gradient headers, animated sidebar,
  card hover effects, progress bar animations, timeline dots, FAB button
- 160+ new i18n keys (DE/EN)
This commit is contained in:
Mustafa Cetin committed 2026-07-29 16:23:18 +00:00
1 parent 86e4b504e4
commit 6d9b42ac37
43 files changed
+4569 -140

No files matched your search

+14 -10
View File
@@ -827,7 +827,7 @@ export default function AppLayout() {
<Sidebar
variant="sidebar"
collapsible="icon"
className="border-r border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-b from-emerald-50/80 via-white to-white dark:from-gray-900 dark:via-emerald-950/20 dark:to-gray-950"
className="border-r border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-b from-emerald-50/80 via-white to-emerald-50/30 dark:from-gray-900 dark:via-emerald-950/20 dark:to-gray-950"
>
<SidebarHeader className="p-4">
<motion.div
@@ -874,16 +874,20 @@ export default function AppLayout() {
className={`group relative transition-all duration-200 min-h-[44px] ${
currentView === item.key
? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm shadow-emerald-100/40 dark:shadow-emerald-900/20'
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 hover:translate-x-0.5'
}`}
>
{currentView === item.key && (
<span className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500" />
<motion.span
layoutId="sidebar-active-indicator"
className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500"
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
/>
)}
<item.icon className={`h-5 w-5 transition-colors duration-200 ${
<item.icon className={`h-5 w-5 transition-all duration-200 ${
currentView === item.key
? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400'
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400 group-hover:scale-110'
}`} />
<span className="text-sm">{t(item.labelKey)}</span>
</SidebarMenuButton>
@@ -970,7 +974,7 @@ export default function AppLayout() {
<SidebarInset>
{/* Announcement Banner */}
<AnnouncementBanner schoolId={currentUser?.schoolId ?? null} />
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
<header className="header-gradient flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
<SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
<Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" />
<Breadcrumb className="min-w-0 flex-1">
@@ -985,7 +989,7 @@ export default function AppLayout() {
</BreadcrumbItem>
<BreadcrumbSeparator><ChevronRight className="h-3 w-3 text-emerald-400/60" /></BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="font-semibold text-gray-900 dark:text-gray-100 truncate">
<BreadcrumbPage className="breadcrumb-active-view truncate">
{breadcrumbLabel}
</BreadcrumbPage>
</BreadcrumbItem>
@@ -1066,7 +1070,7 @@ export default function AppLayout() {
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
className="animate-badge-pulse absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
>
{notifData.unreadCount > 9 ? '9+' : notifData.unreadCount}
</motion.span>
@@ -1207,7 +1211,7 @@ export default function AppLayout() {
</div>
</header>
<div className="flex-1 p-4 md:p-6 bg-white dark:bg-gray-950">
<div className="flex-1 p-4 md:p-6 bg-gradient-to-br from-white via-white to-emerald-50/20 dark:from-gray-950 dark:via-gray-950 dark:to-emerald-950/10">
<AnimatePresence mode="wait">
<motion.div
key={currentView}
@@ -1221,7 +1225,7 @@ export default function AppLayout() {
</AnimatePresence>
</div>
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 py-3 px-4">
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white via-emerald-50/20 to-white dark:from-gray-950 dark:via-emerald-950/10 dark:to-gray-950 py-3 px-4">
<div className="flex items-center justify-between text-xs text-emerald-600/60 dark:text-emerald-400/40">
<span className="flex items-center gap-1">
<Leaf className="h-3 w-3 text-emerald-500" />
+208 -9
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer, ReferenceLine, Tooltip as RechartsTooltip,
@@ -8,6 +8,7 @@ import {
import {
ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home,
Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles,
PenTool, GripVertical, ChevronLeft, ChevronRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -18,6 +19,8 @@ import { Skeleton } from '@/components/ui/skeleton';
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 { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import DrawingCanvas from '@/components/drawing-canvas';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
@@ -107,9 +110,9 @@ const relativeDate = (dateStr: string) => {
};
const resultCountBadge = (count: number) => {
if (count === 0) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
if (count < 5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
if (count === 0) return 'grade-badge-below';
if (count < 5) return 'grade-badge-average';
return 'grade-badge-excellent';
};
// Emerald gradient colors for score distribution bars
@@ -255,6 +258,105 @@ export default function AssessmentsView() {
gradingAssessment?.maxScore ?? null
);
// Scratch pad state
const [scratchPadOpen, setScratchPadOpen] = useState(false);
const [scratchPadWidth, setScratchPadWidth] = useState(480);
const [scratchPadDrawingData, setScratchPadDrawingData] = useState<string | null>(null);
const [scratchPadDrawingId, setScratchPadDrawingId] = useState<string | null>(null);
const [scratchPadSaving, setScratchPadSaving] = useState(false);
const scratchPadResizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
// Load existing scratch pad drawing for the current assessment
const loadScratchPadDrawing = useCallback(async (assessmentId: string) => {
try {
const res = await fetch(`/api/drawings?subjectId=${assessmentId}`);
if (res.ok) {
const drawings = await res.json();
// Find a scratch pad drawing for this assessment
const scratchPad = drawings.find((d: { title: string; description: string }) =>
d.title?.startsWith('Scratch Pad:') || d.description?.startsWith('assessment-scratch-pad:')
);
if (scratchPad) {
setScratchPadDrawingId(scratchPad.id);
setScratchPadDrawingData(scratchPad.drawingData);
} else {
setScratchPadDrawingId(null);
setScratchPadDrawingData(null);
}
}
} catch {
// ignore
}
}, []);
// Save scratch pad drawing
const handleSaveScratchPad = useCallback(async (drawingData: string, imageData: string) => {
if (!gradingAssessment) return;
setScratchPadSaving(true);
try {
const title = `Scratch Pad: ${gradingAssessment.title}`;
const description = `assessment-scratch-pad:${gradingAssessment.id}`;
if (scratchPadDrawingId) {
// Update existing drawing
await fetch(`/api/drawings/${scratchPadDrawingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ drawingData, imageData, title }),
});
} else {
// Create new drawing
const res = await fetch('/api/drawings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
drawingData,
imageData,
subjectId: gradingAssessment.subjectId ?? undefined,
classGroupId: gradingAssessment.classGroupId ?? undefined,
schoolId: currentUser?.schoolId ?? undefined,
isPublic: false,
}),
});
if (res.ok) {
const created = await res.json();
setScratchPadDrawingId(created.id);
}
}
toast.success(t('assessments.scratch_pad_saved'));
} catch {
toast.error(t('error.generic'));
} finally {
setScratchPadSaving(false);
}
}, [gradingAssessment, scratchPadDrawingId, currentUser?.schoolId]);
// Handle mouse move for resizing
const handleResizeMouseMove = useCallback((e: MouseEvent) => {
if (!scratchPadResizeRef.current) return;
const delta = scratchPadResizeRef.current.startX - e.clientX;
const newWidth = Math.min(Math.max(scratchPadResizeRef.current.startWidth + delta, 320), window.innerWidth * 0.7);
setScratchPadWidth(newWidth);
}, []);
const handleResizeMouseUp = useCallback(() => {
scratchPadResizeRef.current = null;
document.removeEventListener('mousemove', handleResizeMouseMove);
document.removeEventListener('mouseup', handleResizeMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, [handleResizeMouseMove]);
const startResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
scratchPadResizeRef.current = { startX: e.clientX, startWidth: scratchPadWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleResizeMouseMove);
document.addEventListener('mouseup', handleResizeMouseUp);
}, [scratchPadWidth, handleResizeMouseMove, handleResizeMouseUp]);
useEffect(() => {
async function load() {
try {
@@ -344,6 +446,9 @@ export default function AssessmentsView() {
const openGrading = async (assessment: Assessment) => {
setGradingAssessment(assessment);
setGradingOpen(true);
setScratchPadOpen(false);
setScratchPadDrawingId(null);
setScratchPadDrawingData(null);
try {
const results = await fetchAssessmentResults(assessment.id);
setGradingResults(results);
@@ -357,6 +462,8 @@ export default function AssessmentsView() {
};
}
setGradingScores(scoresMap);
// Load existing scratch pad drawing
loadScratchPadDrawing(assessment.id);
} catch {
setGradingResults([]);
}
@@ -486,7 +593,7 @@ export default function AssessmentsView() {
</Card>
</motion.div>
) : (
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<Card className="card-shadow-transition 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-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
@@ -580,10 +687,26 @@ export default function AssessmentsView() {
</div>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={() => openGrading(a)}>
<PenLine className="h-3 w-3 mr-1" />
{t('assessments.enter_scores')}
</Button>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
className="min-h-[44px] min-w-[44px] rounded-xl text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
onClick={() => {
setGradingAssessment(a);
setScratchPadOpen(true);
loadScratchPadDrawing(a.id);
}}
title={t('assessments.scratch_pad_open')}
>
<PenTool className="h-4 w-4" />
<span className="sr-only sm:not-sr-only sm:ml-1.5 text-xs">{t('assessments.scratch_pad')}</span>
</Button>
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 min-h-[44px] min-w-[44px]" onClick={() => openGrading(a)}>
<PenLine className="h-3 w-3 mr-1" />
{t('assessments.enter_scores')}
</Button>
</div>
</TableCell>
</TableRow>
);
@@ -685,6 +808,15 @@ export default function AssessmentsView() {
<BarChart3 className="h-3 w-3" />
{t('label.max_score')}: <strong>{gradingAssessment.maxScore ?? '—'}</strong>
</span>
<Button
variant="outline"
size="sm"
className="ml-auto min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
onClick={() => setScratchPadOpen(true)}
>
<PenTool className="h-4 w-4 mr-1.5" />
{t('assessments.scratch_pad')}
</Button>
</DialogDescription>
)}
</DialogHeader>
@@ -792,6 +924,73 @@ export default function AssessmentsView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Scratch Pad Sheet */}
<Sheet open={scratchPadOpen} onOpenChange={setScratchPadOpen}>
<SheetContent
side="right"
className="p-0 gap-0 w-auto sm:max-w-none flex flex-col"
style={{ width: scratchPadWidth }}
>
{/* Resize handle */}
<div
className="absolute top-0 left-0 bottom-0 w-2 cursor-col-resize hover:bg-emerald-200/40 dark:hover:bg-emerald-700/40 active:bg-emerald-300/60 dark:active:bg-emerald-600/60 transition-colors z-10 flex items-center justify-center"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label={t('assessments.scratch_pad_resize')}
tabIndex={0}
>
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<SheetHeader className="px-6 pt-4 pb-2 border-b border-emerald-200/30 dark:border-emerald-900/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<PenTool className="h-4 w-4" />
</div>
<SheetTitle className="text-base font-semibold">
{t('assessments.scratch_pad_title', { title: gradingAssessment?.title ?? '' })}
</SheetTitle>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300"
onClick={() => {
if (scratchPadWidth <= 480) setScratchPadWidth(640);
else if (scratchPadWidth <= 640) setScratchPadWidth(860);
else setScratchPadWidth(480);
}}
title={t('assessments.scratch_pad_resize')}
>
{scratchPadWidth <= 480 ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
</div>
<SheetDescription className="text-xs text-muted-foreground mt-1">
{t('assessments.scratch_pad_description')}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">
<DrawingCanvas
backgroundType="lined"
initialDrawingData={scratchPadDrawingData ?? undefined}
onSave={handleSaveScratchPad}
title={`Scratch Pad: ${gradingAssessment?.title ?? ''}`}
/>
</div>
{scratchPadSaving && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 text-sm shadow-lg">
<div className="h-4 w-4 border-2 border-emerald-600 dark:border-emerald-400 border-t-transparent rounded-full animate-spin" />
{t('assessments.scratch_pad_saving')}
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
+3 -3
View File
@@ -863,7 +863,7 @@ export default function ClassesView() {
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectClass(cls)}
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-hover-lift ${
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-shadow-transition ${
selectedClass?.id === cls.id
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
@@ -900,7 +900,7 @@ export default function ClassesView() {
{/* Small progress indicator */}
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
style={{ width: `${progressPercent(cls)}%` }}
/>
</div>
@@ -1459,7 +1459,7 @@ export default function ClassesView() {
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: idx * 0.03, duration: 0.3 }}
className="flex flex-col items-center gap-1 cursor-pointer"
className="animate-student-appear flex flex-col items-center gap-1 cursor-pointer"
onClick={() => {
useAppStore.getState().navigateToStudentDetail(s.id, 'classes');
}}
+5 -5
View File
@@ -561,7 +561,7 @@ export default function CompetenceFlowerView() {
</Collapsible>
{/* Radar chart */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2">
@@ -786,7 +786,7 @@ export default function CompetenceFlowerView() {
</motion.div>
{/* Breakdown table */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-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 text-white" style={{ backgroundColor: lightenColor(primaryColor, 0.1) }}>
@@ -861,7 +861,7 @@ export default function CompetenceFlowerView() {
</Card>
{/* Progress over time */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<Card className="card-shadow-transition 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-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
@@ -896,7 +896,7 @@ export default function CompetenceFlowerView() {
{/* Comparison + strengths/weaknesses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Comparison card */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-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-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
@@ -963,7 +963,7 @@ export default function CompetenceFlowerView() {
</Card>
{/* Strengths & weaknesses */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-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-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
+747 -5
View File
@@ -48,6 +48,22 @@ import {
Theater,
ShoppingBag,
Tag,
Wallet,
Package,
Coins,
CreditCard,
Headphones,
Tv,
MonitorPlay,
Store,
Palette,
Coffee,
Armchair,
PencilRuler,
Sticker,
Notebook,
History,
Loader2,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -84,6 +100,7 @@ import { useAppStore, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchCompetitions,
fetchPublicCompetitions,
fetchCompetition,
createCompetition,
updateCompetition,
@@ -101,6 +118,63 @@ import {
} from '@/lib/api';
import { toast } from 'sonner';
/* ── Types for Digital Reward Catalog ──────────────────────────────── */
interface CatalogReward {
id: string;
title: string;
description?: string | null;
category: string;
pointsCost: number;
image?: string | null;
stock?: number | null;
isActive: boolean;
isDemo: boolean;
createdAt: string;
_count?: { redemptions: number };
}
interface PointsHistoryEntry {
id: string;
points: number;
source: string;
description?: string | null;
createdAt: string;
}
interface RedemptionEntry {
id: string;
rewardId: string;
pointsSpent: number;
status: string;
note?: string | null;
createdAt: string;
reward?: { title: string; category: string };
}
/* ── Constants ─────────────────────────────────────────────────────── */
const REWARD_CATEGORIES = ['streaming', 'shopping', 'experience', 'merchandise', 'privilege'] as const;
const DEMO_REWARDS: CatalogReward[] = [
{ id: 'demo-netflix', title: 'Netflix-Gutschein', description: '15 EUR Netflix-Gutschein', category: 'streaming', pointsCost: 500, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-spotify', title: 'Spotify Premium', description: '3 Monate Spotify Premium', category: 'streaming', pointsCost: 350, stock: 15, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-disney', title: 'Disney+-Gutschein', description: '1 Monat Disney+', category: 'streaming', pointsCost: 300, stock: 8, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-amazon', title: 'Amazon-Gutschein', description: '10 EUR Amazon-Gutschein', category: 'shopping', pointsCost: 400, stock: 20, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-thalia', title: 'Thalia-Gutschein', description: '10 EUR Thalia-Gutschein', category: 'shopping', pointsCost: 400, stock: 12, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-mediamarkt', title: 'MediaMarkt-Gutschein', description: '15 EUR MediaMarkt-Gutschein', category: 'shopping', pointsCost: 550, stock: 5, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-cinema', title: 'Kino-Ticket', description: 'Ein Kinoticket nach Wahl', category: 'experience', pointsCost: 250, stock: 25, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-theater', title: 'Theater-Ticket', description: 'Ein Theaterbesuch', category: 'experience', pointsCost: 300, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-concert', title: 'Konzert-Ticket', description: 'Konzert-Erlebnis', category: 'experience', pointsCost: 600, stock: 5, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-museum', title: 'Museumseintritt', description: 'Freier Eintritt ins Museum', category: 'experience', pointsCost: 150, stock: 30, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-tshirt', title: 'Schul-T-Shirt', description: 'Offizielles Schul-T-Shirt', category: 'merchandise', pointsCost: 200, stock: 50, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-stickers', title: 'Aufkleber-Set', description: 'Schul-Sticker-Set', category: 'merchandise', pointsCost: 50, stock: 100, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-notebook', title: 'Schulheft', description: 'Schulheft mit Schullogo', category: 'merchandise', pointsCost: 75, stock: 80, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-homework', title: 'Hausaufgaben-Frei', description: 'Eine Hausaufgabe aussetzen', category: 'privilege', pointsCost: 150, stock: 20, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-break', title: 'Extra Pause', description: '5 Minuten extra Pause', category: 'privilege', pointsCost: 100, stock: 30, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-seat', title: 'Sitzplatz wählen', description: 'Sitzplatz für eine Woche frei wählen', category: 'privilege', pointsCost: 200, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
];
/* ── Helpers ─────────────────────────────────────────────────────── */
const COMPETITION_TYPES = ['class', 'inter_class', 'inter_school'] as const;
@@ -234,6 +308,92 @@ function getScoringLabel(type: string): string {
}
}
function getCategoryLabelReward(category: string): string {
switch (category) {
case 'streaming': return t('rewards.category.streaming');
case 'shopping': return t('rewards.category.shopping');
case 'experience': return t('rewards.category.experience');
case 'merchandise': return t('rewards.category.merchandise');
case 'privilege': return t('rewards.category.privilege');
default: return category;
}
}
function getCategoryIcon(category: string, className?: string) {
switch (category) {
case 'streaming': return <Tv className={className} />;
case 'shopping': return <ShoppingBag className={className} />;
case 'experience': return <Ticket className={className} />;
case 'merchandise': return <Package className={className} />;
case 'privilege': return <Crown className={className} />;
default: return <Gift className={className} />;
}
}
function getCategoryColor(category: string): string {
switch (category) {
case 'streaming': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
case 'shopping': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
case 'experience': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300';
case 'merchandise': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
case 'privilege': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
}
function getCategoryBorderColor(category: string): string {
switch (category) {
case 'streaming': return 'border-l-rose-400';
case 'shopping': return 'border-l-amber-400';
case 'experience': return 'border-l-violet-400';
case 'merchandise': return 'border-l-teal-400';
case 'privilege': return 'border-l-emerald-400';
default: return 'border-l-gray-400';
}
}
function getCategoryBgGradient(category: string): string {
switch (category) {
case 'streaming': return 'from-rose-50/50 dark:from-rose-900/10';
case 'shopping': return 'from-amber-50/50 dark:from-amber-900/10';
case 'experience': return 'from-violet-50/50 dark:from-violet-900/10';
case 'merchandise': return 'from-teal-50/50 dark:from-teal-900/10';
case 'privilege': return 'from-emerald-50/50 dark:from-emerald-900/10';
default: return 'from-gray-50/50 dark:from-gray-800/50';
}
}
function getRedemptionStatusLabel(status: string): string {
switch (status) {
case 'pending': return t('rewards.redemption.pending');
case 'approved': return t('rewards.redemption.approved');
case 'rejected': return t('rewards.redemption.rejected');
case 'fulfilled': return t('rewards.redemption.fulfilled');
default: return status;
}
}
function getRedemptionStatusColor(status: string): string {
switch (status) {
case 'pending': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
case 'approved': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
case 'rejected': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
case 'fulfilled': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
}
function getPointsSourceLabel(source: string): string {
switch (source) {
case 'competition': return t('rewards.points_source.competition');
case 'grade': return t('rewards.points_source.grade');
case 'attendance': return t('rewards.points_source.attendance');
case 'homework': return t('rewards.points_source.homework');
case 'bonus': return t('rewards.points_source.bonus');
default: return source;
}
}
function getTimeProgress(startDate: string, endDate: string): number {
const now = Date.now();
const start = new Date(startDate).getTime();
@@ -585,6 +745,28 @@ export default function CompetitionsView() {
const [myClaims, setMyClaims] = useState<RewardClaimData[]>([]);
const [claimingRewardId, setClaimingRewardId] = useState<string | null>(null);
// Digital Reward Catalog
const [catalogRewards, setCatalogRewards] = useState<CatalogReward[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogCategory, setCatalogCategory] = useState<string>('all');
const [pointsBalance, setPointsBalance] = useState(0);
const [pointsEarned, setPointsEarned] = useState(0);
const [pointsSpent, setPointsSpent] = useState(0);
const [pointsHistory, setPointsHistory] = useState<PointsHistoryEntry[]>([]);
const [myRedemptions, setMyRedemptions] = useState<RedemptionEntry[]>([]);
const [redeemDialogOpen, setRedeemDialogOpen] = useState(false);
const [redeemTarget, setRedeemTarget] = useState<CatalogReward | null>(null);
const [isRedeeming, setIsRedeeming] = useState(false);
const [createRewardOpen, setCreateRewardOpen] = useState(false);
const [newRewardForm, setNewRewardForm] = useState({
title: '',
description: '',
category: 'streaming',
pointsCost: '',
stock: '',
});
const [isCreatingReward, setIsCreatingReward] = useState(false);
// Register
const [isRegistering, setIsRegistering] = useState(false);
@@ -596,11 +778,16 @@ export default function CompetitionsView() {
/* ── Fetch competitions ──────────────────────────────────────── */
const loadCompetitions = useCallback(async () => {
if (!schoolId) return;
setIsLoading(true);
try {
const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100);
setCompetitions(result.competitions);
if (schoolId) {
const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100);
setCompetitions(result.competitions);
} else {
// Users without a schoolId can still see public competitions
const result = await fetchPublicCompetitions(100);
setCompetitions(result.competitions);
}
} catch (err) {
console.error('Failed to load competitions:', err);
toast.error('Failed to load competitions');
@@ -615,9 +802,8 @@ export default function CompetitionsView() {
/* ── Fetch my claims ─────────────────────────────────────────── */
const loadMyClaims = useCallback(async () => {
if (!schoolId) return;
try {
const result = await fetchRewardClaims(schoolId, undefined, undefined);
const result = await fetchRewardClaims(schoolId ?? undefined, undefined, undefined);
setMyClaims(result.claims.filter((c) => c.userId === currentUser?.id));
} catch {
// silently fail
@@ -628,6 +814,130 @@ export default function CompetitionsView() {
loadMyClaims();
}, [loadMyClaims]);
/* ── Fetch reward catalog & points ───────────────────────────── */
const loadCatalogData = useCallback(async () => {
setCatalogLoading(true);
try {
const [rewardsRes, pointsRes] = await Promise.all([
fetch('/api/rewards').catch(() => null),
fetch('/api/reward-points').catch(() => null),
]);
if (rewardsRes?.ok) {
const rewardsData = await rewardsRes.json();
if (Array.isArray(rewardsData) && rewardsData.length > 0) {
setCatalogRewards(rewardsData);
} else {
// Use demo rewards when no real rewards exist
setCatalogRewards(DEMO_REWARDS);
}
} else {
setCatalogRewards(DEMO_REWARDS);
}
if (pointsRes?.ok) {
const pointsData = await pointsRes.json();
setPointsBalance(pointsData.balance ?? 0);
setPointsEarned(pointsData.totalEarned ?? 0);
setPointsSpent(pointsData.totalSpent ?? 0);
setPointsHistory(pointsData.pointsHistory ?? []);
setMyRedemptions(pointsData.redemptions ?? []);
} else {
// Demo data
setPointsBalance(750);
setPointsEarned(1000);
setPointsSpent(250);
setPointsHistory([
{ id: 'ph1', points: 200, source: 'competition', description: 'Wettbewerb gewonnen', createdAt: new Date(Date.now() - 86400000).toISOString() },
{ id: 'ph2', points: 100, source: 'attendance', description: '7 Tage Anwesenheitssträhne', createdAt: new Date(Date.now() - 172800000).toISOString() },
{ id: 'ph3', points: 50, source: 'homework', description: 'Hausaufgaben pünktlich abgegeben', createdAt: new Date(Date.now() - 259200000).toISOString() },
{ id: 'ph4', points: 150, source: 'grade', description: 'Sehr gut in Mathematik', createdAt: new Date(Date.now() - 345600000).toISOString() },
{ id: 'ph5', points: 500, source: 'bonus', description: 'Klassensprecher', createdAt: new Date(Date.now() - 432000000).toISOString() },
]);
setMyRedemptions([]);
}
} catch {
setCatalogRewards(DEMO_REWARDS);
setPointsBalance(750);
setPointsEarned(1000);
setPointsSpent(250);
} finally {
setCatalogLoading(false);
}
}, []);
useEffect(() => {
loadCatalogData();
}, [loadCatalogData]);
/* ── Redeem a reward ─────────────────────────────────────────── */
const handleRedeemReward = useCallback(async () => {
if (!redeemTarget) return;
setIsRedeeming(true);
try {
const res = await fetch('/api/rewards/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rewardId: redeemTarget.id }),
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'Insufficient points') {
toast.error(t('rewards.insufficient_points'));
} else if (data.error === 'Out of stock') {
toast.error(t('rewards.out_of_stock'));
} else {
toast.error(data.error || t('rewards.redeem_error'));
}
return;
}
toast.success(t('rewards.redeem_success'));
setRedeemDialogOpen(false);
setRedeemTarget(null);
loadCatalogData();
} catch {
toast.error(t('rewards.redeem_error'));
} finally {
setIsRedeeming(false);
}
}, [redeemTarget, loadCatalogData]);
/* ── Create a new reward (admin) ─────────────────────────────── */
const handleCreateReward = useCallback(async () => {
if (!newRewardForm.title.trim() || !newRewardForm.pointsCost) {
toast.error('Title and points cost are required');
return;
}
setIsCreatingReward(true);
try {
const res = await fetch('/api/rewards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newRewardForm.title.trim(),
description: newRewardForm.description.trim() || null,
category: newRewardForm.category,
pointsCost: parseInt(newRewardForm.pointsCost, 10),
stock: newRewardForm.stock ? parseInt(newRewardForm.stock, 10) : null,
isDemo: true,
}),
});
if (!res.ok) {
const data = await res.json();
toast.error(data.error || t('rewards.redeem_error'));
return;
}
toast.success(t('rewards.create_reward'));
setCreateRewardOpen(false);
setNewRewardForm({ title: '', description: '', category: 'streaming', pointsCost: '', stock: '' });
loadCatalogData();
} catch {
toast.error(t('rewards.redeem_error'));
} finally {
setIsCreatingReward(false);
}
}, [newRewardForm, loadCatalogData]);
/* ── Filtered competitions ───────────────────────────────────── */
const filteredCompetitions = useMemo(() => {
let result = competitions;
@@ -917,6 +1227,7 @@ export default function CompetitionsView() {
<TabsTrigger value="my" className="min-h-[44px]">{t('competition.manage')}</TabsTrigger>
<TabsTrigger value="leaderboard" className="min-h-[44px]">{t('competition.leaderboard')}</TabsTrigger>
<TabsTrigger value="rewards" className="min-h-[44px]">{t('competition.rewards')}</TabsTrigger>
<TabsTrigger value="catalog" className="min-h-[44px]">{t('rewards.title')}</TabsTrigger>
</TabsList>
{/* Overview Tab */}
@@ -1213,6 +1524,283 @@ export default function CompetitionsView() {
)}
</section>
</TabsContent>
{/* ── Digital Reward Catalog Tab ─────────────────────────────── */}
<TabsContent value="catalog" className="space-y-6 mt-4">
{/* Points Balance Header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 shadow-lg">
<Wallet className="h-7 w-7 text-white" />
</div>
<div>
<p className="text-sm text-muted-foreground">{t('rewards.points_balance')}</p>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance}</p>
</div>
</div>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span className="text-muted-foreground">{t('rewards.points_earned')}:</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">{pointsEarned}</span>
</div>
<div className="flex items-center gap-2">
<CreditCard className="h-4 w-4 text-amber-500" />
<span className="text-muted-foreground">{t('rewards.points_spent')}:</span>
<span className="font-semibold text-amber-600 dark:text-amber-400">{pointsSpent}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Category Filter + Create Button */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-wrap gap-2">
<Button
variant={catalogCategory === 'all' ? 'default' : 'outline'}
size="sm"
className={`min-h-[40px] rounded-lg ${catalogCategory === 'all' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
onClick={() => setCatalogCategory('all')}
>
{t('rewards.category.all')}
</Button>
{REWARD_CATEGORIES.map((cat) => (
<Button
key={cat}
variant={catalogCategory === cat ? 'default' : 'outline'}
size="sm"
className={`min-h-[40px] rounded-lg ${catalogCategory === cat ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
onClick={() => setCatalogCategory(cat)}
>
{getCategoryIcon(cat, 'h-4 w-4 mr-1')}
{getCategoryLabelReward(cat)}
</Button>
))}
</div>
{canCreate && (
<Button
className="min-h-[44px] bg-amber-600 hover:bg-amber-700"
onClick={() => setCreateRewardOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
{t('rewards.create_reward')}
</Button>
)}
</div>
{/* Reward Catalog Grid */}
{catalogLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}><CardContent className="p-4"><Skeleton className="h-40" /></CardContent></Card>
))}
</div>
) : (
<AnimatePresence>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{catalogRewards
.filter((r) => catalogCategory === 'all' || r.category === catalogCategory)
.map((reward, idx) => (
<motion.div
key={reward.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2, delay: idx * 0.03 }}
whileHover={{ y: -2 }}
className="h-full"
>
<Card className={`border-l-4 ${getCategoryBorderColor(reward.category)} h-full flex flex-col transition-shadow hover:shadow-md`}>
<CardHeader className={`pb-3 bg-gradient-to-r ${getCategoryBgGradient(reward.category)} to-transparent`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${getCategoryColor(reward.category)} shrink-0`}>
{getCategoryIcon(reward.category, 'h-5 w-5')}
</div>
<div className="min-w-0">
<CardTitle className="text-sm leading-tight truncate">{reward.title}</CardTitle>
<Badge className={`${getCategoryColor(reward.category)} text-xs mt-1`}>
{getCategoryLabelReward(reward.category)}
</Badge>
</div>
</div>
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400 shrink-0">
<Coins className="h-4 w-4" />
<span className="font-bold text-sm">{reward.pointsCost}</span>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 pb-3 space-y-3">
{reward.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{reward.description}</p>
)}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{reward.stock !== null && reward.stock !== undefined
? t('rewards.stock_remaining').replace('{count}', String(reward.stock))
: t('rewards.unlimited')}
</span>
{pointsBalance >= reward.pointsCost ? (
<span className="text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
</span>
) : (
<span className="text-rose-500 dark:text-rose-400 flex items-center gap-1">
<XCircle className="h-3 w-3" />
</span>
)}
</div>
</CardContent>
<CardFooter className="pt-0 pb-4">
<Button
size="sm"
className={`w-full min-h-[40px] rounded-lg ${
pointsBalance >= reward.pointsCost
? 'bg-emerald-600 hover:bg-emerald-700'
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed'
}`}
disabled={pointsBalance < reward.pointsCost}
onClick={() => {
setRedeemTarget(reward);
setRedeemDialogOpen(true);
}}
>
<Gift className="h-4 w-4 mr-1" />
{t('rewards.redeem')}
</Button>
</CardFooter>
</Card>
</motion.div>
))}
</div>
</AnimatePresence>
)}
{/* Earn Points Section */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2 text-base">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<Zap className="h-4 w-4" />
</div>
{t('rewards.earn_points')}
</CardTitle>
<CardDescription>{t('rewards.earn_points_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{[
{ icon: Trophy, label: t('rewards.earn_competition'), color: 'emerald' },
{ icon: Star, label: t('rewards.earn_grades'), color: 'amber' },
{ icon: Calendar, label: t('rewards.earn_attendance'), color: 'teal' },
{ icon: BookOpen, label: t('rewards.earn_homework'), color: 'violet' },
{ icon: Sparkles, label: t('rewards.earn_bonus'), color: 'rose' },
].map((item) => {
const Icon = item.icon;
const colorMap: Record<string, { bg: string; text: string }> = {
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400' },
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400' },
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400' },
rose: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400' },
};
const c = colorMap[item.color] || colorMap.emerald;
return (
<div key={item.label} className={`p-3 rounded-xl ${c.bg}`}>
<Icon className={`h-5 w-5 ${c.text} mb-2`} />
<p className="text-xs font-medium text-gray-700 dark:text-gray-300">{item.label}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Points History & My Redemptions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Points History */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2 text-base">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<History className="h-4 w-4" />
</div>
{t('rewards.points_history')}
</CardTitle>
</CardHeader>
<CardContent className="max-h-96 overflow-y-auto">
{pointsHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<History className="h-8 w-8 mb-2 opacity-30" />
<p className="text-xs">{t('rewards.no_rewards')}</p>
</div>
) : (
<div className="space-y-2">
{pointsHistory.map((entry) => (
<div key={entry.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-100 dark:bg-emerald-900/30 shrink-0">
<Plus className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium truncate">{entry.description || getPointsSourceLabel(entry.source)}</p>
<p className="text-xs text-muted-foreground">{formatDate(entry.createdAt)}</p>
</div>
</div>
<span className="text-sm font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">+{entry.points}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* My Redemptions */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2 text-base">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Gift className="h-4 w-4" />
</div>
{t('rewards.my_redemptions')}
</CardTitle>
</CardHeader>
<CardContent className="max-h-96 overflow-y-auto">
{myRedemptions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Gift className="h-8 w-8 mb-2 opacity-30" />
<p className="text-xs">{t('rewards.no_rewards')}</p>
</div>
) : (
<div className="space-y-2">
{myRedemptions.map((redemption) => (
<div key={redemption.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-amber-100 dark:bg-amber-900/30 shrink-0">
{getCategoryIcon(redemption.reward?.category ?? 'streaming', 'h-3 w-3 text-amber-600 dark:text-amber-400')}
</div>
<div className="min-w-0">
<p className="text-xs font-medium truncate">{redemption.reward?.title ?? 'Reward'}</p>
<p className="text-xs text-muted-foreground">{formatDate(redemption.createdAt)}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className={`${getRedemptionStatusColor(redemption.status)} text-xs`}>
{getRedemptionStatusLabel(redemption.status)}
</Badge>
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">-{redemption.pointsSpent}</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* ── Competition Detail Dialog ──────────────────────────── */}
@@ -1716,6 +2304,160 @@ export default function CompetitionsView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Redeem Reward Dialog ──────────────────────────────────── */}
<Dialog open={redeemDialogOpen} onOpenChange={setRedeemDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Gift className="h-5 w-5 text-amber-500" />
{t('rewards.redeem_confirm')}
</DialogTitle>
<DialogDescription>
{redeemTarget
? t('rewards.redeem_confirm_desc')
.replace('{points}', String(redeemTarget.pointsCost))
.replace('{reward}', redeemTarget.title)
: ''}
</DialogDescription>
</DialogHeader>
{redeemTarget && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${getCategoryColor(redeemTarget.category)} shrink-0`}>
{getCategoryIcon(redeemTarget.category, 'h-6 w-6')}
</div>
<div>
<p className="font-semibold">{redeemTarget.title}</p>
<div className="flex items-center gap-2 mt-1">
<Badge className={getCategoryColor(redeemTarget.category)}>
{getCategoryLabelReward(redeemTarget.category)}
</Badge>
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<Coins className="h-3 w-3" />
{redeemTarget.pointsCost}
</span>
</div>
</div>
</div>
<div className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<span className="text-sm text-muted-foreground">{t('rewards.points_balance')}</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance}</span>
</div>
{pointsBalance >= redeemTarget.pointsCost && (
<div className="flex items-center justify-between p-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10">
<span className="text-sm text-muted-foreground">After redemption:</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance - redeemTarget.pointsCost}</span>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" className="min-h-[44px]" onClick={() => setRedeemDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
onClick={handleRedeemReward}
disabled={isRedeeming || !redeemTarget || pointsBalance < (redeemTarget?.pointsCost ?? Infinity)}
>
{isRedeeming ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Gift className="h-4 w-4 mr-2" />
)}
{t('rewards.redeem')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Create Reward Dialog ──────────────────────────────────── */}
<Dialog open={createRewardOpen} onOpenChange={setCreateRewardOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5 text-amber-500" />
{t('rewards.create_reward')}
</DialogTitle>
<DialogDescription>
{t('rewards.create_reward')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<Label>{t('competition.name')} *</Label>
<Input
value={newRewardForm.title}
onChange={(e) => setNewRewardForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Reward title"
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.description')}</Label>
<Input
value={newRewardForm.description}
onChange={(e) => setNewRewardForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Description..."
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.category')}</Label>
<Select value={newRewardForm.category} onValueChange={(v) => setNewRewardForm((f) => ({ ...f, category: v }))}>
<SelectTrigger className="mt-1 min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REWARD_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>{getCategoryLabelReward(cat)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('rewards.points_cost')} *</Label>
<Input
type="number"
value={newRewardForm.pointsCost}
onChange={(e) => setNewRewardForm((f) => ({ ...f, pointsCost: e.target.value }))}
placeholder="100"
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>Stock</Label>
<Input
type="number"
value={newRewardForm.stock}
onChange={(e) => setNewRewardForm((f) => ({ ...f, stock: e.target.value }))}
placeholder="Unlimited"
className="mt-1 min-h-[44px]"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="min-h-[44px]" onClick={() => setCreateRewardOpen(false)}>
{t('action.cancel')}
</Button>
<Button
className="min-h-[44px] bg-amber-600 hover:bg-amber-700"
onClick={handleCreateReward}
disabled={isCreatingReward}
>
{isCreatingReward ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{t('rewards.create_reward')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+697 -3
View File
@@ -64,6 +64,19 @@ import {
PenSquare,
Eye,
Tag,
Shield,
Database,
Server,
Cpu,
UserPlus,
School,
LayoutList,
Activity as ActivityIcon,
CheckCircle2,
AlertCircle,
TrendingDown,
BarChart2,
PieChart as PieChartIcon,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -74,6 +87,7 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import {
Select,
SelectContent,
@@ -91,6 +105,24 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import {
Tooltip,
TooltipContent,
@@ -476,6 +508,499 @@ function ParentDashboard({ currentUser, setCurrentView, locale }: {
</CardContent>
</Card>
</motion.div>
{/* Parent Children's Recent Grades */}
<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">
<Calculator className="h-4 w-4" />
</div>
{t('parent.children_grades')}
</CardTitle>
</CardHeader>
<CardContent>
{children.length > 0 ? (
<div className="space-y-3">
{children.map((child) => (
<div key={child.studentId} className="p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-violet-400 to-purple-500 text-white text-[10px] font-bold shrink-0">
{child.firstName[0]}{child.lastName[0]}
</div>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{child.firstName} {child.lastName}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 ml-auto">{child.className}</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
{[
{ subject: t('student.notebook_math'), grade: '2.3' },
{ subject: t('student.notebook_german'), grade: '1.8' },
{ subject: t('student.notebook_english'), grade: '2.7' },
{ subject: t('student.notebook_science'), grade: '2.1' },
].map((g) => (
<div key={g.subject} className="px-2 py-1.5 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100/50 dark:border-emerald-900/20 text-center">
<p className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{g.subject}</p>
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{g.grade}</p>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<Calculator className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('parent.no_children')}</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Parent Competition Results & Calendar Events */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Competition Results */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
<Trophy className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.competition_results')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-amber-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-amber-50 to-yellow-50/50 dark:from-amber-900/20 dark:to-yellow-900/10 border border-amber-200/30 dark:border-amber-900/20">
<div className="text-center">
<Trophy className="h-8 w-8 text-amber-400/60 dark:text-amber-600/40 mx-auto mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent.no_competition_results')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Calendar Events */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full cursor-pointer min-h-[44px]" onClick={() => setCurrentView('calendar')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
<CalendarIconNav className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.calendar_events')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-2">
{[
{ title: locale === 'de' ? 'Elternsprechtag' : 'Parent-Teacher Day', date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), color: 'from-emerald-400 to-teal-500' },
{ title: locale === 'de' ? 'Schulfest' : 'School Festival', date: new Date(Date.now() + 12 * 24 * 60 * 60 * 1000), color: 'from-amber-400 to-orange-500' },
{ title: locale === 'de' ? 'Zeugnisausgabe' : 'Report Card Day', date: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000), color: 'from-violet-400 to-purple-500' },
].map((event) => {
const daysUntil = Math.ceil((event.date.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return (
<div key={event.title} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/50 dark:border-gray-800/20 min-h-[44px]">
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${event.color} text-white shadow-sm shrink-0`}>
<CalendarDays className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{event.title}</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500">{event.date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })}</p>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">{daysUntil}d</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Parent Announcements */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
// ─── School Admin Dashboard Component ──────────────────────────────────
function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
currentUser: CurrentUser;
setCurrentView: (view: ViewName) => void;
locale: string;
data: DashboardData;
}) {
// Mock audit log data
const auditLogEntries = [
{ id: '1', action: 'USER_LOGIN', user: 'Max Mustermann', timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(), details: 'Teacher login' },
{ id: '2', action: 'ASSESSMENT_CREATED', user: 'Anna Schmidt', timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), details: 'Math assessment created' },
{ id: '3', action: 'STUDENT_ENROLLED', user: 'System', timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(), details: 'New student enrolled' },
{ id: '4', action: 'REPORT_GENERATED', user: 'Peter Weber', timestamp: new Date(Date.now() - 1000 * 60 * 120).toISOString(), details: 'Class report generated' },
{ id: '5', action: 'SETTINGS_UPDATED', user: 'Admin', timestamp: new Date(Date.now() - 1000 * 60 * 180).toISOString(), details: 'School settings updated' },
];
// Mock system health data
const systemHealth = {
status: 'healthy' as const,
uptime: '99.9%',
activeUsers: Math.floor(data.stats.totalStudents * 0.3 + data.stats.totalClasses * 2),
storageUsed: 42,
apiRequestsToday: 1247,
};
// Enrollment trend data for chart
const enrollmentData = [
{ month: locale === 'de' ? 'Jan' : 'Jan', students: Math.floor(data.stats.totalStudents * 0.85) },
{ month: locale === 'de' ? 'Feb' : 'Feb', students: Math.floor(data.stats.totalStudents * 0.87) },
{ month: locale === 'de' ? 'Mär' : 'Mar', students: Math.floor(data.stats.totalStudents * 0.9) },
{ month: locale === 'de' ? 'Apr' : 'Apr', students: Math.floor(data.stats.totalStudents * 0.92) },
{ month: locale === 'de' ? 'Mai' : 'May', students: Math.floor(data.stats.totalStudents * 0.95) },
{ month: locale === 'de' ? 'Jun' : 'Jun', students: data.stats.totalStudents },
];
// Grade distribution data for pie chart
const gradeDistribution = [
{ name: '1', value: 25, color: '#10b981' },
{ name: '2', value: 35, color: '#14b8a6' },
{ name: '3', value: 25, color: '#f59e0b' },
{ name: '4', value: 12, color: '#f97316' },
{ name: '5-6', value: 3, color: '#ef4444' },
];
const getActionIcon = (action: string) => {
switch (action) {
case 'USER_LOGIN': return UserCheck;
case 'ASSESSMENT_CREATED': return ClipboardCheck;
case 'STUDENT_ENROLLED': return UserPlus;
case 'REPORT_GENERATED': return FileText;
case 'SETTINGS_UPDATED': return Settings;
default: return Activity;
}
};
const getActionColor = (action: string) => {
switch (action) {
case 'USER_LOGIN': return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400';
case 'ASSESSMENT_CREATED': return 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400';
case 'STUDENT_ENROLLED': return 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400';
case 'REPORT_GENERATED': return 'bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400';
case 'SETTINGS_UPDATED': return 'bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400';
default: return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400';
}
};
const relativeTime = (dateStr: string) => {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return locale === 'de' ? 'Gerade eben' : 'Just now';
if (diffMins < 60) return locale === 'de' ? `Vor ${diffMins} Min.` : `${diffMins}m ago`;
if (diffHours < 24) return locale === 'de' ? `Vor ${diffHours} Std.` : `${diffHours}h ago`;
if (diffDays === 1) return t('date.yesterday');
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
return date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short' });
};
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6 pb-8"
>
{/* Admin Welcome Header */}
<motion.div variants={itemVariants}>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3.5">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-rose-400 to-orange-500 text-white shadow-lg shadow-rose-300/30 ring-2 ring-rose-200/30 dark:ring-rose-800/20">
<Shield className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-rose-600 via-orange-600 to-amber-500 dark:from-rose-400 dark:via-orange-400 dark:to-amber-300 bg-clip-text text-transparent">
{t('admin.welcome_back')}, {currentUser.firstName}!
</h2>
<p className="text-rose-600/60 dark:text-rose-400/40 mt-0.5 text-sm">{t('admin.dashboard_title')}</p>
</div>
</div>
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-rose-50 to-orange-50/50 dark:from-rose-900/20 dark:to-orange-900/10 border border-rose-200/40 dark:border-rose-900/30 text-xs font-medium text-rose-700 dark:text-rose-300 shadow-sm">
<CalendarDays className="h-3.5 w-3.5" />
<span className="font-semibold">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
</motion.div>
{/* School Overview Stats */}
<motion.div variants={itemVariants} className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ icon: Users, label: t('admin.total_students'), value: data.stats.totalStudents, color: 'from-emerald-400 to-teal-500', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200/40 dark:border-emerald-900/30' },
{ icon: BarChart3, label: t('admin.total_classes'), value: data.stats.totalClasses, color: 'from-teal-400 to-cyan-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' },
{ icon: GraduationCap, label: t('admin.total_teachers'), value: Math.max(1, Math.floor(data.stats.totalClasses * 0.8)), color: 'from-amber-400 to-orange-500', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200/40 dark:border-amber-900/30' },
{ icon: Heart, label: t('admin.total_parents'), value: Math.floor(data.stats.totalStudents * 1.4), color: 'from-rose-400 to-pink-500', bg: 'bg-rose-50 dark:bg-rose-900/20', border: 'border-rose-200/40 dark:border-rose-900/30' },
].map((stat) => (
<motion.div
key={stat.label}
whileHover={{ scale: 1.03, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Card className={`border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] ${stat.bg} ${stat.border}`}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} text-white shadow-sm shrink-0`}>
<stat.icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{stat.label}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 leading-none mt-0.5"><CountUp target={stat.value} /></p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
{/* System Health Card */}
<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">
<Server className="h-4 w-4" />
</div>
{t('admin.system_health')}
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs shadow-sm">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('admin.health_excellent')}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ icon: ActivityIcon, label: t('admin.uptime'), value: systemHealth.uptime, color: 'text-emerald-600 dark:text-emerald-400' },
{ icon: Users, label: t('admin.active_users'), value: systemHealth.activeUsers.toString(), color: 'text-teal-600 dark:text-teal-400' },
{ icon: Database, label: t('admin.storage_used'), value: `${systemHealth.storageUsed}%`, color: 'text-amber-600 dark:text-amber-400' },
{ icon: Cpu, label: t('admin.api_requests'), value: systemHealth.apiRequestsToday.toLocaleString(), color: 'text-violet-600 dark:text-violet-400' },
].map((metric) => (
<div key={metric.label} className="p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className="flex items-center gap-2 mb-1.5">
<metric.icon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
<span className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{metric.label}</span>
</div>
<p className={`text-lg font-bold ${metric.color}`}>{metric.value}</p>
</div>
))}
</div>
{/* Storage usage bar */}
<div className="mt-4 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">{t('admin.storage_used')}</span>
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">{systemHealth.storageUsed}%</span>
</div>
<Progress value={systemHealth.storageUsed} className="h-2" />
</div>
</CardContent>
</Card>
</motion.div>
{/* School Statistics Charts */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-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-amber-50/50 to-transparent dark:from-amber-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-amber-400 to-orange-500 text-white shadow-sm">
<BarChart2 className="h-4 w-4" />
</div>
{t('admin.school_statistics')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Enrollment Trend */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('admin.enrollment_trend')}</h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={enrollmentData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="#9ca3af" />
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" />
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
/>
<Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Grade Distribution */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('admin.grade_distribution')}</h4>
<div className="flex items-center gap-4">
<ResponsiveContainer width="60%" height={200}>
<PieChart>
<Pie
data={gradeDistribution}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{gradeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
<div className="space-y-2">
{gradeDistribution.map((grade) => (
<div key={grade.name} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: grade.color }} />
<span className="text-xs text-gray-600 dark:text-gray-400">{grade.name}: {grade.value}%</span>
</div>
))}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Audit Log & User Management */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Audit Log */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-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-violet-400 to-violet-500 text-white shadow-sm">
<LayoutList className="h-4 w-4" />
</div>
{t('admin.audit_log')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('admin.audit_log_desc')}</span>
</CardTitle>
</CardHeader>
<CardContent>
{auditLogEntries.length === 0 ? (
<div className="text-center py-8">
<LayoutList className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('admin.no_audit_entries')}</p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto scrollbar-education pr-1">
{auditLogEntries.map((entry) => {
const ActionIcon = getActionIcon(entry.action);
return (
<div key={entry.id} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${getActionColor(entry.action)} shrink-0`}>
<ActionIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{entry.details}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{entry.user} · {relativeTime(entry.timestamp)}
</p>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">{entry.action.replace('_', ' ')}</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* User Management Quick Actions */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-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-teal-400 to-teal-500 text-white shadow-sm">
<UserPlus className="h-4 w-4" />
</div>
{t('admin.user_management')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ icon: GraduationCap, label: t('admin.manage_teachers'), desc: t('admin.add_teacher'), color: 'from-amber-400 to-orange-500', view: 'settings' as ViewName },
{ icon: Users, label: t('admin.manage_students'), desc: t('admin.add_student'), color: 'from-emerald-400 to-teal-500', view: 'classes' as ViewName },
{ icon: Heart, label: t('admin.manage_parents'), desc: t('admin.manage_parents'), color: 'from-rose-400 to-pink-500', view: 'parents' as ViewName },
{ icon: School, label: t('admin.school_settings'), desc: t('admin.school_settings'), color: 'from-violet-400 to-purple-500', view: 'settings' as ViewName },
].map((action) => (
<motion.button
key={action.label}
onClick={() => setCurrentView(action.view)}
whileHover={{ scale: 1.01, x: 2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-teal-200/60 dark:hover:border-teal-800/30 transition-all min-h-[44px]"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${action.color} text-white shadow-sm shrink-0 transition-transform duration-200 group-hover:scale-110`}>
<action.icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{action.label}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{action.desc}</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-teal-500 transition-colors shrink-0" />
</motion.button>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
onClick={() => setCurrentView('classes')}
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white min-h-[44px] px-4 font-semibold shadow-sm"
>
<Plus className="h-4 w-4 mr-1.5" />
{t('admin.add_class')}
</Button>
<Button
variant="outline"
onClick={() => setCurrentView('settings')}
className="border-teal-300 dark:border-teal-700 hover:bg-teal-50 dark:hover:bg-teal-900/20 text-teal-700 dark:text-teal-300 min-h-[44px] px-4"
>
<Settings className="h-4 w-4 mr-1.5" />
{t('admin.school_settings')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Announcements Management */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
{/* School Newsletter */}
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
@@ -1024,6 +1549,75 @@ export default function DashboardView() {
</CardContent>
</Card>
</motion.div>
{/* Student Grades Trend Chart */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm">
<TrendingUp className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.my_grades_trend')}</CardTitle>
</div>
<div className="flex items-center gap-1">
<ArrowUpRight className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{t('student.grade_trend_up')}</span>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-1">
<ResponsiveContainer width="100%" height={160}>
<BarChart data={[
{ subject: t('student.notebook_math').substring(0, 3), grade: 2.3 },
{ subject: t('student.notebook_german').substring(0, 3), grade: 1.8 },
{ subject: t('student.notebook_english').substring(0, 3), grade: 2.7 },
{ subject: t('student.notebook_science').substring(0, 3), grade: 2.1 },
]}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="subject" tick={{ fontSize: 10 }} stroke="#9ca3af" />
<YAxis domain={[1, 6]} reversed tick={{ fontSize: 10 }} stroke="#9ca3af" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</motion.div>
{/* Student Competition Standings */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]" onClick={() => setCurrentView('competitions')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
<Trophy className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.competition_standings')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-amber-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex items-center justify-center h-20 rounded-lg bg-gradient-to-br from-amber-50 to-yellow-50/50 dark:from-amber-900/20 dark:to-yellow-900/10 border border-amber-200/30 dark:border-amber-900/20">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
<Trophy className="h-5 w-5" />
</div>
<div className="text-center">
<p className="text-lg font-bold text-amber-600 dark:text-amber-400">#12</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student.my_rank')} · 245 {t('student.points')}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Student School Announcements */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
@@ -1039,6 +1633,18 @@ export default function DashboardView() {
);
}
// ─── School Admin Dashboard ─────────────────────────────────────────
if (currentUser?.role === 'SCHOOL_ADMIN') {
return (
<SchoolAdminDashboard
currentUser={currentUser}
setCurrentView={setCurrentView}
locale={locale}
data={data}
/>
);
}
return (
<motion.div
variants={containerVariants}
@@ -1209,7 +1815,7 @@ export default function DashboardView() {
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Card className={`card-hover-lift border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
<CardContent className={`p-5 ${colors.bg}`}>
<div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0">
@@ -1426,6 +2032,94 @@ export default function DashboardView() {
</Card>
</motion.div>
{/* ===== TEACHER COMPETENCE PROGRESS RADAR ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-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-teal-50/50 to-transparent dark:from-teal-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-teal-400 to-emerald-500 text-white shadow-sm">
<Flower2 className="h-4 w-4" />
</div>
{t('teacher.competence_progress')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('teacher.competence_progress_desc')}</span>
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={[
{ subject: t('student.notebook_math'), current: 3.5, target: 4 },
{ subject: t('student.notebook_german'), current: 3.8, target: 4 },
{ subject: t('student.notebook_english'), current: 2.9, target: 4 },
{ subject: t('student.notebook_science'), current: 3.2, target: 4 },
{ subject: locale === 'de' ? 'Musik' : 'Music', current: 3.6, target: 4 },
{ subject: locale === 'de' ? 'Sport' : 'PE', current: 4.0, target: 4 },
]}>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} />
<PolarRadiusAxis angle={30} domain={[0, 4]} tick={{ fontSize: 10 }} />
<Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} />
<Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px' }} />
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</motion.div>
{/* ===== UPCOMING LESSONS ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-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-violet-50/50 to-transparent dark:from-violet-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-violet-400 to-violet-500 text-white shadow-sm">
<Clock className="h-4 w-4" />
</div>
{t('teacher.upcoming_lessons')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{data.classesOverview.length === 0 ? (
<div className="text-center py-6">
<Clock className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('teacher.no_lessons')}</p>
</div>
) : (
<div className="space-y-2">
{data.classesOverview.slice(0, 4).map((cls, idx) => {
const times = ['08:00 – 08:45', '09:00 – 09:45', '10:00 – 10:45', '11:00 – 11:45'];
const subjects = [t('student.notebook_math'), t('student.notebook_german'), t('student.notebook_english'), t('student.notebook_science')];
const colors = ['from-amber-400 to-orange-500', 'from-emerald-400 to-teal-500', 'from-rose-400 to-pink-500', 'from-violet-400 to-purple-500'];
return (
<motion.div
key={cls.id}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="group flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-violet-200/60 dark:hover:border-violet-800/30 transition-colors min-h-[44px]"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${colors[idx % colors.length]} text-white shadow-sm shrink-0`}>
<BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{subjects[idx % subjects.length]} — {cls.name}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">{times[idx % times.length]} · {t('teacher.student_count')}: {cls.studentCount}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{times[idx % times.length].split(' – ')[0]}</Badge>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-violet-500 transition-colors" />
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* ===== TODAY'S SCHEDULE ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
@@ -1808,7 +2502,7 @@ export default function DashboardView() {
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
<div className="activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry) => {
const catColor = entry.competency.category.color ?? '#10b981';
const studentName = `${entry.student.firstName} ${entry.student.lastName}`;
@@ -1818,7 +2512,7 @@ export default function DashboardView() {
key={entry.id}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden"
className="activity-timeline-item group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden"
>
{/* Left border color indicator */}
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl transition-all duration-200 group-hover:w-1.5" style={{ backgroundColor: catColor }} />
+50 -6
View File
@@ -26,6 +26,7 @@ import {
ZoomIn,
ZoomOut,
Activity,
Highlighter,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
@@ -73,7 +74,7 @@ export interface StrokePoint {
export interface Stroke {
id: string;
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser';
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
color: string;
width: number;
points: StrokePoint[];
@@ -93,7 +94,7 @@ export interface DrawingCanvasProps {
title?: string;
}
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser';
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted';
type GuideMode = 'off' | 'basic' | 'circles' | 'perspective';
@@ -111,6 +112,11 @@ const PEN_COLORS = [
'#533483', '#2b2d42', '#8d99ae', '#ef233c',
];
const HIGHLIGHTER_COLORS = [
'#fef08a', '#bbf7d0', '#bfdbfe', '#fbcfe8',
'#fed7aa', '#c4b5fd', '#99f6e4', '#fca5a5',
];
const AUTO_SAVE_INTERVAL = 30000;
const AUTO_SAVE_DEBOUNCE = 5000; // Debounced auto-save: 5s after last stroke
const LOCAL_STORAGE_KEY = 'ct_drawing_draft';
@@ -355,6 +361,11 @@ function drawStroke(
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)';
ctx.lineWidth = stroke.width * 2;
} else if (stroke.tool === 'highlighter') {
ctx.globalCompositeOperation = 'multiply';
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width * 4;
ctx.globalAlpha = 0.35;
} else {
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
@@ -364,7 +375,7 @@ function drawStroke(
ctx.globalAlpha = 0.7;
}
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser') {
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser' || stroke.tool === 'highlighter') {
if (stroke.points.length === 1) {
const p = stroke.points[0];
const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width;
@@ -487,6 +498,16 @@ export default function DrawingCanvas({
const [bgType, setBgType] = useState<BackgroundType>(backgroundType);
const [guideMode, setGuideMode] = useState<GuideMode>('off');
// Set default color for highlighter
const handleToolChange = useCallback((tool: ToolType) => {
setActiveTool(tool);
if (tool === 'highlighter' && strokeColor === '#000000') {
setStrokeColor('#fef08a');
} else if (tool !== 'highlighter' && strokeColor === '#fef08a') {
setStrokeColor('#000000');
}
}, [strokeColor]);
// UI state
const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
@@ -885,6 +906,7 @@ export default function DrawingCanvas({
const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [
{ id: 'pencil', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_pencil') },
{ id: 'pen', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_pen') },
{ id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') },
{ id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') },
{ id: 'rectangle', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_rectangle') },
{ id: 'circle', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_circle') },
@@ -943,7 +965,7 @@ export default function DrawingCanvas({
<Button
variant="ghost"
size="sm"
onClick={() => setActiveTool(tool.id)}
onClick={() => handleToolChange(tool.id)}
className={`h-10 w-10 min-touch transition-all rounded-lg ${
activeTool === tool.id
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105'
@@ -997,9 +1019,9 @@ export default function DrawingCanvas({
/>
))}
</div>
<p className="mt-2 text-xs font-medium text-gray-500">
<div className="mt-2 text-xs font-medium text-gray-500">
{t('drawing.tool_pen')} {t('drawing.color').toLowerCase()}
</p>
</div>
<div className="grid grid-cols-8 gap-1.5">
{PEN_COLORS.map((color) => (
<button
@@ -1015,6 +1037,28 @@ export default function DrawingCanvas({
/>
))}
</div>
{activeTool === 'highlighter' && (
<>
<p className="mt-2 text-xs font-medium text-gray-500">
{t('drawing.tool_highlighter')}
</p>
<div className="grid grid-cols-8 gap-1.5">
{HIGHLIGHTER_COLORS.map((color) => (
<button
key={color}
onClick={() => setStrokeColor(color)}
className={`h-8 w-8 min-touch rounded-full border-2 transition-all hover:scale-110 ${
strokeColor === color
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
: 'border-gray-300'
}`}
style={{ backgroundColor: color }}
aria-label={`Highlighter color ${color}`}
/>
))}
</div>
</>
)}
<div className="mt-2 flex items-center gap-2">
<Label className="text-xs text-gray-500">{t('drawing.color')}</Label>
<input
+647 -61
View File
@@ -17,6 +17,9 @@ import {
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
Users as UsersIcon, Radio, MousePointer2, Columns2,
ChevronRight, XCircle, Trophy, Zap, Flame, Dumbbell, Heart, RotateCcw, ArrowRight, Play,
StickyNote, LayoutTemplate, Network, GitCompare, CalendarDays,
FolderOpen, Search as SearchIcon, ClipboardCopy, Tag,
Minus, CircleDot, Sparkles as SparklesIcon,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -104,6 +107,101 @@ interface PageVersion {
createdAt: string;
}
// ─── Sticky Note Type ────────────────────────────────────────────────
interface StickyNoteData {
id: string;
x: number;
y: number;
width: number;
height: number;
color: string;
text: string;
pageId: string;
}
// ─── Section Type ────────────────────────────────────────────────────
interface SectionData {
id: string;
name: string;
color: string;
pageIds: string[];
}
// ─── Page Template Definitions ───────────────────────────────────────
const PAGE_TEMPLATES: Array<{
key: string;
titleKey: string;
descKey: string;
icon: React.ElementType;
background: string;
getContent: () => string;
}> = [
{
key: 'blank',
titleKey: 'notebooks.page_template_blank',
descKey: 'notebooks.type_blank_desc',
icon: File,
background: 'blank',
getContent: () => '',
},
{
key: 'cornell',
titleKey: 'notebooks.page_template_cornell',
descKey: 'notebooks.page_template_cornell_desc',
icon: LayoutTemplate,
background: 'lined',
getContent: () => `<div style="display:grid;grid-template-columns:1fr 2fr;grid-template-rows:auto 1fr;gap:8px;min-height:500px;border:1px solid #e5e7eb;border-radius:8px;padding:8px;"><div style="border-right:1px solid #e5e7eb;padding-right:8px;font-size:12px;color:#9ca3af;"><b>Stichworte</b><br><br><br></div><div style="padding-left:8px;"><b>Notizen</b><br><br><br></div><div style="grid-column:1/-1;border-top:1px solid #e5e7eb;padding-top:8px;"><b>Zusammenfassung</b><br><br></div></div>`,
},
{
key: 'mindmap',
titleKey: 'notebooks.page_template_mindmap',
descKey: 'notebooks.page_template_mindmap_desc',
icon: Network,
background: 'blank',
getContent: () => `<div style="display:flex;align-items:center;justify-content:center;min-height:500px;"><div style="position:relative;width:100%;height:100%;"><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:120px;height:120px;border-radius:50%;border:3px solid #10b981;display:flex;align-items:center;justify-content:center;font-weight:bold;color:#10b981;">Zentrales Thema</div><div style="position:absolute;top:20%;left:15%;width:80px;height:80px;border-radius:50%;border:2px solid #3b82f6;display:flex;align-items:center;justify-content:center;font-size:12px;color:#3b82f6;">Idee 1</div><div style="position:absolute;top:20%;right:15%;width:80px;height:80px;border-radius:50%;border:2px solid #ef4444;display:flex;align-items:center;justify-content:center;font-size:12px;color:#ef4444;">Idee 2</div><div style="position:absolute;bottom:20%;left:15%;width:80px;height:80px;border-radius:50%;border:2px solid #f59e0b;display:flex;align-items:center;justify-content:center;font-size:12px;color:#f59e0b;">Idee 3</div><div style="position:absolute;bottom:20%;right:15%;width:80px;height:80px;border-radius:50%;border:2px solid #8b5cf6;display:flex;align-items:center;justify-content:center;font-size:12px;color:#8b5cf6;">Idee 4</div></div></div>`,
},
{
key: 'venn',
titleKey: 'notebooks.page_template_venn',
descKey: 'notebooks.page_template_venn_desc',
icon: GitCompare,
background: 'blank',
getContent: () => `<div style="display:flex;align-items:center;justify-content:center;min-height:500px;position:relative;"><div style="position:absolute;width:260px;height:260px;border-radius:50%;border:3px solid #3b82f6;background:rgba(59,130,246,0.08);display:flex;align-items:center;justify-content:center;left:calc(50% - 180px);"><span style="font-size:12px;color:#3b82f6;font-weight:bold;">Menge A</span></div><div style="position:absolute;width:260px;height:260px;border-radius:50%;border:3px solid #ef4444;background:rgba(239,68,68,0.08);display:flex;align-items:center;justify-content:center;left:calc(50% - 80px);"><span style="font-size:12px;color:#ef4444;font-weight:bold;">Menge B</span></div><div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:11px;color:#6b7280;font-weight:bold;">Gemeinsam</div></div>`,
},
{
key: 'tchart',
titleKey: 'notebooks.page_template_tchart',
descKey: 'notebooks.page_template_tchart_desc',
icon: Columns2,
background: 'lined',
getContent: () => `<div style="display:grid;grid-template-columns:1fr 1fr;min-height:500px;border:1px solid #e5e7eb;border-radius:8px;"><div style="border-right:2px solid #10b981;padding:12px;"><b>Seite A</b><br><br><br></div><div style="padding:12px;"><b>Seite B</b><br><br><br></div></div>`,
},
{
key: 'weekly',
titleKey: 'notebooks.page_template_weekly',
descKey: 'notebooks.page_template_weekly_desc',
icon: CalendarDays,
background: 'grid',
getContent: () => `<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px;min-height:500px;"><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#f0fdf4;"><b>Montag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#eff6ff;"><b>Dienstag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#fef3c7;"><b>Mittwoch</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#fce7f3;"><b>Donnerstag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#f3e8ff;"><b>Freitag</b><br><br></div></div>`,
},
];
const STICKY_NOTE_COLORS = [
{ key: 'yellow', color: '#fef08a', border: '#fbbf24' },
{ key: 'green', color: '#bbf7d0', border: '#34d399' },
{ key: 'blue', color: '#bfdbfe', border: '#60a5fa' },
{ key: 'pink', color: '#fbcfe8', border: '#f472b6' },
{ key: 'orange', color: '#fed7aa', border: '#fb923c' },
{ key: 'purple', color: '#ddd6fe', border: '#a78bfa' },
];
const SECTION_COLORS = [
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
];
// ─── Constants ───────────────────────────────────────────────────────
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [
@@ -416,7 +514,7 @@ function WysiwygToolbar({ editorRef, onFormatChange }: { editorRef: React.RefObj
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
<div className="glass-toolbar flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
{/* Format group */}
<Tooltip>
<TooltipTrigger asChild>
@@ -635,7 +733,7 @@ function NotebookCard({
>
{/* Cover section */}
<div
className="relative h-28 flex items-center justify-center overflow-hidden"
className="notebook-cover relative h-28 flex items-center justify-center overflow-hidden"
style={{
background: `linear-gradient(135deg, ${notebook.color}, ${notebook.color}cc)`,
}}
@@ -1051,16 +1149,18 @@ function NotebookDetailView({
onToggleBookmark,
onTogglePublic,
onReorderPages,
onDuplicatePage,
}: {
notebook: Notebook;
subjectName: string | null;
onBack: () => void;
onUpdatePage: (pageId: string, data: Partial<NotebookPage>) => Promise<void>;
onAddPage: () => void;
onAddPage: (templateKey?: string, background?: string, content?: string) => Promise<void>;
onDeletePage: (pageId: string) => void;
onToggleBookmark: (pageId: string) => void;
onTogglePublic: () => void;
onReorderPages: (pageOrders: Array<{ id: string; pageNumber: number }>) => Promise<void>;
onDuplicatePage: (pageId: string) => Promise<void>;
}) {
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [pageContent, setPageContent] = useState('');
@@ -1084,6 +1184,27 @@ function NotebookDetailView({
// Collaboration state
const [collabActivityOpen, setCollabActivityOpen] = useState(false);
// Sticky notes state
const [stickyNotes, setStickyNotes] = useState<StickyNoteData[]>([]);
const [showStickyNoteDialog, setShowStickyNoteDialog] = useState(false);
const [newStickyNoteColor, setNewStickyNoteColor] = useState('#fef08a');
const [editingStickyNote, setEditingStickyNote] = useState<StickyNoteData | null>(null);
// Search within notebook state
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Array<{ pageId: string; pageTitle: string; snippet: string; pageNumber: number }>>([]);
// Page template chooser state
const [templateChooserOpen, setTemplateChooserOpen] = useState(false);
// Sections state
const [sections, setSections] = useState<SectionData[]>([]);
const [showSectionDialog, setShowSectionDialog] = useState(false);
const [newSectionName, setNewSectionName] = useState('');
const [newSectionColor, setNewSectionColor] = useState('#10b981');
const [showTableOfContents, setShowTableOfContents] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedContentRef = useRef<string>('');
@@ -1450,6 +1571,94 @@ function NotebookDetailView({
setDragOverPageId(null);
}, [dragOverPageId, handleDrop]);
// ─── Sticky Note Handlers ──────────────────────────────────────────
const addStickyNote = useCallback(() => {
if (!currentPage) return;
const note: StickyNoteData = {
id: `sticky_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
x: 10 + Math.random() * 30,
y: 10 + Math.random() * 30,
width: 180,
height: 120,
color: newStickyNoteColor,
text: '',
pageId: currentPage.id,
};
setStickyNotes(prev => [...prev, note]);
setShowStickyNoteDialog(false);
}, [currentPage, newStickyNoteColor]);
const updateStickyNote = useCallback((id: string, updates: Partial<StickyNoteData>) => {
setStickyNotes(prev => prev.map(n => n.id === id ? { ...n, ...updates } : n));
}, []);
const deleteStickyNote = useCallback((id: string) => {
setStickyNotes(prev => prev.filter(n => n.id !== id));
}, []);
const currentPageStickyNotes = useMemo(
() => stickyNotes.filter(n => n.pageId === currentPageId),
[stickyNotes, currentPageId]
);
// ─── Search Within Notebook ────────────────────────────────────────
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults([]);
return;
}
const q = query.toLowerCase();
const results: Array<{ pageId: string; pageTitle: string; snippet: string; pageNumber: number }> = [];
for (const page of pages) {
const plainText = (page.textContent ?? '').replace(/<[^>]*>/g, '');
const title = page.title ?? `${t('notebooks.page')} ${page.pageNumber}`;
const combined = (title + ' ' + plainText).toLowerCase();
if (combined.includes(q)) {
const idx = combined.indexOf(q);
const start = Math.max(0, idx - 30);
const end = Math.min(plainText.length, idx + query.length + 30);
const snippet = (start > 0 ? '...' : '') + plainText.substring(start, end) + (end < plainText.length ? '...' : '');
results.push({ pageId: page.id, pageTitle: title, snippet, pageNumber: page.pageNumber });
}
}
setSearchResults(results);
}, [pages]);
// ─── Section Handlers ──────────────────────────────────────────────
const addSection = useCallback(() => {
if (!newSectionName.trim()) return;
const section: SectionData = {
id: `section_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
name: newSectionName.trim(),
color: newSectionColor,
pageIds: [],
};
setSections(prev => [...prev, section]);
setNewSectionName('');
setShowSectionDialog(false);
}, [newSectionName, newSectionColor]);
const deleteSection = useCallback((sectionId: string) => {
setSections(prev => prev.filter(s => s.id !== sectionId));
}, []);
const getSectionForPage = useCallback((pageId: string): SectionData | null => {
return sections.find(s => s.pageIds.includes(pageId)) ?? null;
}, [sections]);
// ─── Page Template Handler ─────────────────────────────────────────
const handleAddFromTemplate = useCallback(async (templateKey: string) => {
const template = PAGE_TEMPLATES.find(t => t.key === templateKey);
if (!template) return;
setTemplateChooserOpen(false);
await onAddPage(templateKey, template.background, template.getContent());
}, [onAddPage]);
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
return (
@@ -1626,6 +1835,219 @@ function NotebookDetailView({
<Clock className="w-4 h-4" />
</Button>
{/* Search within notebook */}
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<SearchIcon className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.search_pages')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-80 p-0 rounded-xl">
<div className="p-3">
<Input
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t('notebooks.search_pages_placeholder')}
className="min-h-[44px]"
autoFocus
/>
</div>
{searchQuery.trim() && (
<div className="border-t border-gray-100 dark:border-gray-800">
<ScrollArea className="max-h-60">
{searchResults.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{t('notebooks.search_no_results')}
</div>
) : (
<div className="p-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{searchResults.length} {t('notebooks.search_results')}
</div>
{searchResults.map((result) => (
<button
key={result.pageId}
onClick={() => { setCurrentPageId(result.pageId); setSearchOpen(false); }}
className="w-full text-left p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[44px]"
>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{result.pageTitle}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{result.snippet}
</div>
</button>
))}
</div>
)}
</ScrollArea>
</div>
)}
</PopoverContent>
</Popover>
{/* Sticky note button */}
<Popover open={showStickyNoteDialog} onOpenChange={setShowStickyNoteDialog}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<StickyNote className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.sticky_note_add')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-auto p-3 rounded-xl">
<p className="text-sm font-medium mb-2">{t('notebooks.sticky_note_color')}</p>
<div className="flex items-center gap-2 mb-3">
{STICKY_NOTE_COLORS.map((sc) => (
<button
key={sc.key}
onClick={() => setNewStickyNoteColor(sc.color)}
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 ${
newStickyNoteColor === sc.color ? 'border-gray-900 dark:border-white scale-110 shadow-md' : 'border-transparent'
}`}
style={{ backgroundColor: sc.color }}
/>
))}
</div>
<Button onClick={addStickyNote} className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700" size="sm">
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.sticky_note_add')}
</Button>
</PopoverContent>
</Popover>
{/* Sections button */}
<Popover open={showSectionDialog} onOpenChange={setShowSectionDialog}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<FolderOpen className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.sections')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-sm font-semibold">{t('notebooks.sections')}</h3>
</div>
<div className="p-3 space-y-3">
<Input
value={newSectionName}
onChange={(e) => setNewSectionName(e.target.value)}
placeholder={t('notebooks.section_name_placeholder')}
className="min-h-[44px]"
/>
<div className="flex items-center gap-2 flex-wrap">
{SECTION_COLORS.map((color) => (
<button
key={color}
onClick={() => setNewSectionColor(color)}
className={`w-6 h-6 rounded-full border-2 transition-all hover:scale-110 ${
newSectionColor === color ? 'border-gray-900 dark:border-white scale-110' : 'border-transparent'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<Button
onClick={addSection}
disabled={!newSectionName.trim()}
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.section_add')}
</Button>
</div>
{sections.length > 0 && (
<div className="border-t border-gray-100 dark:border-gray-800 p-2 space-y-1">
{sections.map((section) => (
<div key={section.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: section.color }} />
<span className="flex-1 truncate text-gray-700 dark:text-gray-300">{section.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSection(section.id)}
className="h-7 w-7 p-0 text-gray-400 hover:text-red-500"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Table of Contents button */}
<Popover open={showTableOfContents} onOpenChange={setShowTableOfContents}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<BookMarked className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.table_of_contents')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-sm font-semibold">{t('notebooks.table_of_contents')}</h3>
</div>
<ScrollArea className="max-h-60">
{pages.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{t('notebooks.no_sections')}
</div>
) : (
<div className="p-2 space-y-0.5">
{pages.map((page) => {
const section = getSectionForPage(page.id);
return (
<div key={page.id}>
{section && page.id === section.pageIds[0] && (
<div className="flex items-center gap-2 px-2 py-1 mt-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: section.color }} />
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">{section.name}</span>
</div>
)}
<button
onClick={() => { setCurrentPageId(page.id); setShowTableOfContents(false); }}
className="w-full text-left px-3 py-1.5 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center gap-2 min-h-[36px]"
>
<span className="text-xs text-gray-400 font-mono w-5 shrink-0">{page.pageNumber}</span>
<span className="truncate text-gray-700 dark:text-gray-300">{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}</span>
{page.isBookmark && <Bookmark className="w-3 h-3 text-amber-500 fill-amber-500 shrink-0" />}
</button>
</div>
);
})}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* Public toggle */}
<Button
variant="ghost"
@@ -1641,7 +2063,7 @@ function NotebookDetailView({
<div className="flex flex-1 overflow-hidden">
{/* Left sidebar: page navigation */}
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col">
<div className="p-3">
<div className="p-3 space-y-2">
<Button
onClick={onAddPage}
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
@@ -1650,6 +2072,15 @@ function NotebookDetailView({
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.add_page')}
</Button>
<Button
onClick={() => setTemplateChooserOpen(true)}
variant="outline"
className="w-full min-h-[44px]"
size="sm"
>
<LayoutTemplate className="w-4 h-4 mr-1" />
{t('notebooks.page_template')}
</Button>
</div>
<ScrollArea className="flex-1 max-h-[calc(100vh-280px)]">
@@ -1657,65 +2088,101 @@ function NotebookDetailView({
{pages.map((page, idx) => {
const isDragged = draggedPageId === page.id;
const isDragOver = dragOverPageId === page.id;
const section = getSectionForPage(page.id);
const isFirstInSection = section && section.pageIds[0] === page.id;
return (
<motion.div
key={page.id}
initial={{ opacity: 0, x: -10 }}
animate={{
opacity: isDragged ? 0.5 : 1,
x: 0,
scale: isDragged ? 0.95 : 1,
}}
transition={{ delay: idx * 0.03 }}
layout
className={`rounded-lg transition-all ${
currentPageId === page.id
? 'ring-2 ring-emerald-500'
: ''
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
isDragged ? 'shadow-md' : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(page.id)}
onDragEnd={handleDragEnd}
onTouchStart={(e) => handleTouchStart(e, page.id)}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Drag handle */}
<div className="flex items-center">
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
<div className="flex-1">
{/* Page thumbnail */}
<PageThumbnail page={page} notebookType={notebook.notebookType} />
</div>
</div>
{/* Page info below thumbnail */}
<button
onClick={() => { setCurrentPageId(page.id); }}
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
<React.Fragment key={page.id}>
{/* Section divider */}
{section && isFirstInSection && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700"
>
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: section.color }} />
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 truncate flex-1">{section.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSection(section.id)}
className="h-6 w-6 p-0 text-gray-400 hover:text-red-500 shrink-0"
>
<X className="w-3 h-3" />
</Button>
</motion.div>
)}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{
opacity: isDragged ? 0.5 : 1,
x: 0,
scale: isDragged ? 0.95 : 1,
}}
transition={{ delay: idx * 0.03 }}
layout
className={`rounded-lg transition-all ${
currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
? 'ring-2 ring-emerald-500'
: ''
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
isDragged ? 'shadow-md' : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(page.id)}
onDragEnd={handleDragEnd}
onTouchStart={(e) => handleTouchStart(e, page.id)}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
{page.pageNumber}
</span>
<span className="truncate flex-1">
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
</span>
{page.isBookmark && (
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
)}
</button>
</motion.div>
{/* Drag handle */}
<div className="flex items-center">
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
<div className="flex-1">
{/* Page thumbnail */}
<PageThumbnail page={page} notebookType={notebook.notebookType} />
</div>
</div>
{/* Page info below thumbnail */}
<button
onClick={() => { setCurrentPageId(page.id); }}
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
{page.pageNumber}
</span>
<span className="truncate flex-1">
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
</span>
{page.isBookmark && (
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
)}
{/* Duplicate page button */}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); onDuplicatePage(page.id); }}
className="h-6 w-6 flex items-center justify-center rounded text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 shrink-0 transition-colors"
>
<ClipboardCopy className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">{t('notebooks.duplicate_page')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
</motion.div>
</React.Fragment>
);
})}
</div>
@@ -1958,6 +2425,47 @@ function NotebookDetailView({
</div>
</motion.div>
))}
{/* Sticky Notes Overlay */}
{currentPageStickyNotes.map((note) => (
<motion.div
key={note.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute z-30 shadow-lg rounded-md overflow-hidden group"
style={{
left: `${note.x}%`,
top: `${note.y}%`,
width: `${note.width}px`,
minHeight: `${note.height}px`,
backgroundColor: note.color,
}}
>
<div className="flex items-center justify-between px-2 py-1 bg-black/5">
<span className="text-xs font-medium text-gray-600">{t('notebooks.sticky_note')}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setEditingStickyNote(note)}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-black/10 text-gray-600"
>
<Edit3 className="w-3 h-3" />
</button>
<button
onClick={() => deleteStickyNote(note.id)}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-black/10 text-gray-600"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<Textarea
value={note.text}
onChange={(e) => updateStickyNote(note.id, { text: e.target.value })}
className="border-0 bg-transparent shadow-none resize-none text-sm min-h-[80px] p-2 focus-visible:ring-0"
placeholder={t('notebooks.sticky_note_edit') + '...'}
/>
</motion.div>
))}
</div>
</div>
)}
@@ -2019,6 +2527,40 @@ function NotebookDetailView({
</div>
</div>
{/* Page Template Chooser Dialog */}
<Dialog open={templateChooserOpen} onOpenChange={setTemplateChooserOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LayoutTemplate className="w-5 h-5 text-emerald-500" />
{t('notebooks.page_template_choose')}
</DialogTitle>
<DialogDescription>{t('notebooks.page_template')}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
{PAGE_TEMPLATES.map((template) => {
const TemplateIcon = template.icon;
return (
<button
key={template.key}
onClick={() => handleAddFromTemplate(template.key)}
className="flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all min-h-[100px] hover:border-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
>
<TemplateIcon className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t(template.titleKey)}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 text-center">{t(template.descKey)}</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTemplateChooserOpen(false)} className="min-h-[44px]">
{t('action.cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Version History Dialog */}
<VersionHistoryDialog
open={versionHistoryOpen}
@@ -3315,12 +3857,14 @@ export default function NotebooksView() {
});
}, [selectedNotebook]);
const handleAddPage = useCallback(async () => {
const handleAddPage = useCallback(async (templateKey?: string, background?: string, content?: string) => {
if (!selectedNotebook) return;
const pagesCount = selectedNotebook.pages?.length ?? 0;
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
pageNumber: pagesCount + 1,
background: selectedNotebook.notebookType,
background: background ?? selectedNotebook.notebookType,
title: templateKey && templateKey !== 'blank' ? t(`notebooks.page_template_${templateKey}`) : null,
textContent: content ?? null,
});
setSelectedNotebook(prev => {
if (!prev) return prev;
@@ -3332,6 +3876,32 @@ export default function NotebooksView() {
toast.success(t('notebooks.page_added'));
}, [selectedNotebook]);
const handleDuplicatePage = useCallback(async (pageId: string) => {
if (!selectedNotebook) return;
const page = selectedNotebook.pages?.find(p => p.id === pageId);
if (!page) return;
const pagesCount = selectedNotebook.pages?.length ?? 0;
try {
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
pageNumber: pagesCount + 1,
background: page.background,
title: (page.title ?? '') + ' ' + t('notebooks.copy_suffix'),
textContent: page.textContent,
drawingData: page.drawingData,
});
setSelectedNotebook(prev => {
if (!prev) return prev;
return {
...prev,
pages: [...(prev.pages ?? []), newPage],
};
});
toast.success(t('notebooks.duplicate_page_success'));
} catch {
toast.error(t('notebooks.duplicate_page_error'));
}
}, [selectedNotebook]);
const handleDeletePage = useCallback(async (pageId: string) => {
if (!selectedNotebook) return;
await apiDelete(`/api/notebooks/${selectedNotebook.id}/pages/${pageId}`);
@@ -3525,6 +4095,7 @@ export default function NotebooksView() {
onToggleBookmark={handleToggleBookmark}
onTogglePublic={handleTogglePublic}
onReorderPages={handleReorderPages}
onDuplicatePage={handleDuplicatePage}
/>
</div>
);
@@ -4013,6 +4584,21 @@ export default function NotebooksView() {
)}
</div>
{/* Floating Action Button for new notebook */}
{!selectedNotebook && !showArchived && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="fab"
onClick={() => setCreateOpen(true)}
title={t('notebooks.create')}
>
<Plus className="w-6 h-6" />
</motion.button>
)}
{/* Eco footer bar */}
<div className="bg-emerald-50 dark:bg-emerald-900/20 border-t border-emerald-200 dark:border-emerald-800 px-4 sm:px-6 py-3">
<div className="flex items-center gap-3 text-emerald-700 dark:text-emerald-300 text-sm">
+3 -5
View File
@@ -69,10 +69,8 @@ function getMasteryIcon(level: number) {
}
const masteryBadge = (level: number) => {
// Use CSS class-based mastery badges for deeper polish
if (level <= 2) return 'mastery-badge-low';
if (level <= 4) return 'mastery-badge-medium';
return 'mastery-badge-high';
// Use CSS class-based mastery indicators for deeper polish
return `mastery-indicator mastery-indicator-${level}`;
};
const masteryBorderColor = (level: number) => {
@@ -899,7 +897,7 @@ export default function ProgressEntriesView() {
<motion.div
whileHover={{ scale: 1.005 }}
transition={{ duration: 0.15 }}
className={`card-hover-lift flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
className={`card-shadow-transition flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
isSelected && batchMode ? 'ring-2 ring-red-300/60 dark:ring-red-700/50 bg-red-50/30 dark:bg-red-900/10' : ''
}`}
style={{ borderLeftColor: catColor }}
+129
View File
@@ -1130,6 +1130,135 @@ function PrivacyTab({ currentUser }: { currentUser: { id: string; role: string;
</Card>
)}
{/* ── Consent Management Card ─────────────────────────────── */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Shield className="h-4 w-4" />
</div>
{t('dsgvo.consent_management')}
</CardTitle>
<CardDescription>{t('dsgvo.consent_management_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'data_processing', icon: Database, color: 'emerald' },
{ key: 'communication', icon: Mail, color: 'teal' },
{ key: 'analytics', icon: BarChart3, color: 'amber' },
{ key: 'third_party', icon: Users, color: 'violet' },
].map((consent) => {
const Icon = consent.icon;
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/30 dark:border-emerald-900/20' },
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/30 dark:border-teal-900/20' },
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200/30 dark:border-amber-900/20' },
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/30 dark:border-violet-900/20' },
};
const c = colorMap[consent.color] || colorMap.emerald;
return (
<div key={consent.key} className={`flex items-center justify-between p-4 rounded-xl ${c.bg} border ${c.border}`}>
<div className="flex items-center gap-3">
<Icon className={`h-5 w-5 ${c.text}`} />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t(`dsgvo.consent_${consent.key}`)}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t(`dsgvo.consent_${consent.key}_desc`)}</p>
</div>
</div>
<Badge className={`${c.bg} ${c.text} border ${c.border} text-xs`}>
{t('dsgvo.consent_granted')}
</Badge>
</div>
);
})}
</CardContent>
</Card>
{/* ── Data Retention Settings Card ─────────────────────────────── */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<HardDrive className="h-4 w-4" />
</div>
{t('dsgvo.retention_settings')}
</CardTitle>
<CardDescription>{t('dsgvo.retention_settings_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'grades', icon: GraduationCap, color: 'emerald', defaultYears: 3 },
{ key: 'attendance', icon: CalendarCheck, color: 'teal', defaultYears: 2 },
{ key: 'behavior', icon: Heart, color: 'violet', defaultYears: 1 },
].map((retention) => {
const Icon = retention.icon;
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
emerald: { bg: 'bg-emerald-50/50 dark:bg-emerald-900/10', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/30 dark:border-emerald-900/20' },
teal: { bg: 'bg-teal-50/50 dark:bg-teal-900/10', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/30 dark:border-teal-900/20' },
violet: { bg: 'bg-violet-50/50 dark:bg-violet-900/10', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/30 dark:border-violet-900/20' },
};
const c = colorMap[retention.color] || colorMap.emerald;
return (
<div key={retention.key} className={`flex items-center justify-between p-4 rounded-xl ${c.bg} border ${c.border}`}>
<div className="flex items-center gap-3">
<Icon className={`h-5 w-5 ${c.text}`} />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t(`dsgvo.retention_${retention.key}`)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{retention.defaultYears}</span>
<span className="text-xs text-muted-foreground">{t('dsgvo.retention_years')}</span>
</div>
</div>
);
})}
</CardContent>
</Card>
{/* ── Cookie Consent Management Card ─────────────────────────────── */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-rose-400 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<Shield className="h-4 w-4" />
</div>
{t('dsgvo.cookie_settings')}
</CardTitle>
<CardDescription>{t('dsgvo.cookie_consent')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t('dsgvo.cookie_essential')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('dsgvo.cookie_essential_desc')}</p>
</div>
</div>
<Badge className="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-xs">
{t('dsgvo.consent_granted')}
</Badge>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<BarChart3 className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t('dsgvo.cookie_analytics')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('dsgvo.cookie_analytics_desc')}</p>
</div>
</div>
<Badge className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs">
{t('dsgvo.consent_revoked')}
</Badge>
</div>
</CardContent>
</Card>
{/* ── Delete Account Confirmation Dialog ─────────────────────── */}
<AlertDialog open={deleteDialogOpen} onOpenChange={(open) => { if (!open) { setDeleteDialogOpen(false); setDeletePassword(''); } }}>
<AlertDialogContent>