feat: styling improvements + grade trend chart + student comparison

- Enhanced reports, grading, attendance, settings views with gradient headers, hover effects, section icons
- Added grade trend over time chart (AreaChart with reversed Y-axis)
- Added student comparison feature in competence flower (dual radar overlay)
- Added 6 new i18n keys for grade trend and comparison
- Lint passes cleanly
This commit is contained in:
Mustafa Cetin committed 2026-07-29 05:58:27 +00:00
1 parent 0c1f739989
commit 456cdf9714
6 files changed
+413 -94

No files matched your search

+34 -31
View File
@@ -259,16 +259,16 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
const data = dateMap.get(dateKey); const data = dateMap.get(dateKey);
if (!data || data.total === 0) return 'bg-gray-100 dark:bg-gray-800'; if (!data || data.total === 0) return 'bg-gray-100 dark:bg-gray-800';
const rate = data.present / data.total; const rate = data.present / data.total;
if (rate >= 0.9) return 'bg-emerald-500 dark:bg-emerald-600'; if (rate >= 0.9) return 'bg-gradient-to-br from-emerald-500 to-emerald-600 dark:from-emerald-500 dark:to-emerald-600';
if (rate >= 0.7) return 'bg-emerald-400 dark:bg-emerald-500'; if (rate >= 0.7) return 'bg-gradient-to-br from-emerald-400 to-teal-500 dark:from-emerald-500 dark:to-emerald-600';
if (rate >= 0.5) return 'bg-emerald-300 dark:bg-emerald-700'; if (rate >= 0.5) return 'bg-gradient-to-br from-emerald-300 to-emerald-400 dark:from-emerald-700 dark:to-emerald-800';
if (rate >= 0.3) return 'bg-emerald-200 dark:bg-emerald-800'; if (rate >= 0.3) return 'bg-gradient-to-br from-amber-200 to-amber-300 dark:from-emerald-800 dark:to-emerald-900';
return 'bg-rose-300 dark:bg-rose-700'; return 'bg-gradient-to-br from-rose-300 to-rose-400 dark:from-rose-700 dark:to-rose-800';
} }
return ( return (
<Card className="border-0 shadow-lg bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20"> <Card className="border-0 shadow-lg rounded-xl bg-gradient-to-br from-white to-emerald-50/30 dark:from-gray-900 dark:to-emerald-950/20 overflow-hidden">
<CardHeader className="pb-2"> <CardHeader className="pb-2 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-emerald-500" /> <CalendarCheck className="h-5 w-5 text-emerald-500" />
{t('attendance.calendar')} — {monthNames[month]} {year} {t('attendance.calendar')} — {monthNames[month]} {year}
@@ -290,7 +290,7 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
<div <div
className={`aspect-square rounded-sm flex items-center justify-center text-xs className={`aspect-square rounded-sm flex items-center justify-center text-xs
${getHeatColor(cell.day, cell.dateKey)} ${getHeatColor(cell.day, cell.dateKey)}
${cell.day ? 'hover:ring-2 hover:ring-emerald-400 transition-all cursor-default' : ''} ${cell.day ? 'hover:ring-2 hover:ring-emerald-400 hover:scale-110 transition-all duration-150 cursor-default' : ''}
`} `}
> >
{cell.day && ( {cell.day && (
@@ -323,11 +323,11 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
<span>{t('attendance.absent')}</span> <span>{t('attendance.absent')}</span>
<div className="flex gap-0.5"> <div className="flex gap-0.5">
<div className="w-3 h-3 rounded-sm bg-gray-100 dark:bg-gray-800" /> <div className="w-3 h-3 rounded-sm bg-gray-100 dark:bg-gray-800" />
<div className="w-3 h-3 rounded-sm bg-rose-300 dark:bg-rose-700" /> <div className="w-3 h-3 rounded-sm bg-gradient-to-br from-rose-300 to-rose-400 dark:from-rose-700 dark:to-rose-800" />
<div className="w-3 h-3 rounded-sm bg-emerald-200 dark:bg-emerald-800" /> <div className="w-3 h-3 rounded-sm bg-gradient-to-br from-amber-200 to-amber-300 dark:from-emerald-800 dark:to-emerald-900" />
<div className="w-3 h-3 rounded-sm bg-emerald-300 dark:bg-emerald-700" /> <div className="w-3 h-3 rounded-sm bg-gradient-to-br from-emerald-300 to-emerald-400 dark:from-emerald-700 dark:to-emerald-800" />
<div className="w-3 h-3 rounded-sm bg-emerald-400 dark:bg-emerald-500" /> <div className="w-3 h-3 rounded-sm bg-gradient-to-br from-emerald-400 to-teal-500 dark:from-emerald-500 dark:to-emerald-600" />
<div className="w-3 h-3 rounded-sm bg-emerald-500 dark:bg-emerald-600" /> <div className="w-3 h-3 rounded-sm bg-gradient-to-br from-emerald-500 to-emerald-600 dark:from-emerald-500 dark:to-emerald-600" />
</div> </div>
<span>{t('attendance.present')}</span> <span>{t('attendance.present')}</span>
</div> </div>
@@ -401,14 +401,15 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{cards.map((card) => ( {cards.map((card, idx) => (
<motion.div <motion.div
key={card.label} key={card.label}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3, delay: idx * 0.06 }}
whileHover={{ y: -2 }}
> >
<Card className={`border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow`}> <Card className={`border-2 ${card.border} shadow-md hover:shadow-lg transition-shadow rounded-xl overflow-hidden`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<card.icon className="w-4 h-4" /> <card.icon className="w-4 h-4" />
@@ -551,7 +552,7 @@ function SessionDetail({
size="sm" size="sm"
onClick={handleSaveAll} onClick={handleSaveAll}
disabled={saving} disabled={saving}
className="bg-emerald-600 hover:bg-emerald-700 text-white" className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl"
> >
{saving ? ( {saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" /> <Loader2 className="h-4 w-4 animate-spin mr-1" />
@@ -604,7 +605,7 @@ function SessionDetail({
].map((s) => ( ].map((s) => (
<div <div
key={s.label} key={s.label}
className="text-center p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800" className="text-center p-3 rounded-xl bg-gradient-to-br from-gray-50 to-gray-50/50 dark:from-gray-800/60 dark:to-gray-800/30 border border-gray-100 dark:border-gray-800 hover:shadow-md transition-shadow"
> >
<div className={`text-xl font-bold ${s.color}`}>{s.value}</div> <div className={`text-xl font-bold ${s.color}`}>{s.value}</div>
<div className="text-xs text-muted-foreground">{s.label}</div> <div className="text-xs text-muted-foreground">{s.label}</div>
@@ -912,7 +913,7 @@ export default function AttendanceView() {
<CalendarCheck className="h-8 w-8 text-emerald-500" /> <CalendarCheck className="h-8 w-8 text-emerald-500" />
{t('attendance.title')} {t('attendance.title')}
</h1> </h1>
<p className="text-muted-foreground mt-1">{t('attendance.subtitle')}</p> <p className="text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('attendance.subtitle')}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -936,13 +937,15 @@ export default function AttendanceView() {
{/* New session button */} {/* New session button */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}> <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none" <Button
disabled={!currentClassId} className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
> disabled={!currentClassId}
<Plus className="h-4 w-4 mr-1" /> >
{t('attendance.new_session')} <Plus className="h-4 w-4 mr-1" />
</Button> {t('attendance.new_session')}
</Button>
</motion.div>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -986,7 +989,7 @@ export default function AttendanceView() {
<Button <Button
onClick={handleCreateSession} onClick={handleCreateSession}
disabled={creating || !newDate} disabled={creating || !newDate}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white" className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl"
> >
{creating ? ( {creating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <Loader2 className="h-4 w-4 animate-spin mr-2" />
@@ -1104,14 +1107,14 @@ export default function AttendanceView() {
{/* Content */} {/* Content */}
{currentClassId && !loading && ( {currentClassId && !loading && (
<Tabs defaultValue="sessions" className="space-y-4"> <Tabs defaultValue="sessions" className="space-y-4">
<TabsList className="bg-gray-100 dark:bg-gray-800"> <TabsList className="bg-emerald-50 dark:bg-emerald-900/20 rounded-xl flex flex-wrap gap-1 h-auto p-1">
<TabsTrigger value="sessions" className="data-[state=active]:bg-emerald-100 data-[state=active]:dark:bg-emerald-900"> <TabsTrigger value="sessions" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.status')} {t('attendance.status')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="stats" className="data-[state=active]:bg-emerald-100 data-[state=active]:dark:bg-emerald-900"> <TabsTrigger value="stats" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.stats')} {t('attendance.stats')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="calendar" className="data-[state=active]:bg-emerald-100 data-[state=active]:dark:bg-emerald-900"> <TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.calendar')} {t('attendance.calendar')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
+89 -3
View File
@@ -19,7 +19,7 @@ import {
} from 'recharts'; } from 'recharts';
import { import {
Flower2, Download, User, Target, TrendingUp, Award, AlertTriangle, Sparkles, GitCompareArrows, Flower2, Download, User, Target, TrendingUp, Award, AlertTriangle, Sparkles, GitCompareArrows,
Palette, Check, Palette, Check, X,
BookOpen, Calculator, PenLine, MessageSquare, FlaskConical, Palette as PaletteIcon, Footprints, Music, Globe, Lightbulb, BookOpen, Calculator, PenLine, MessageSquare, FlaskConical, Palette as PaletteIcon, Footprints, Music, Globe, Lightbulb,
Sprout, Leaf, TreePine, Trees, Star, BarChart3, Ruler, Compass, Triangle, Calculator as Abacus, Sprout, Leaf, TreePine, Trees, Star, BarChart3, Ruler, Compass, Triangle, Calculator as Abacus,
LucideIcon, LucideIcon,
@@ -141,6 +141,9 @@ export default function CompetenceFlowerView() {
const [showClassAverage, setShowClassAverage] = useState(true); const [showClassAverage, setShowClassAverage] = useState(true);
const [primaryColor, setPrimaryColor] = useState('#10b981'); const [primaryColor, setPrimaryColor] = useState('#10b981');
const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerOpen, setColorPickerOpen] = useState(false);
const [compareStudentId, setCompareStudentId] = useState<string>('');
const [compareFlowerData, setCompareFlowerData] = useState<FlowerData | null>(null);
const [compareLoading, setCompareLoading] = useState(false);
// Load stored color on mount // Load stored color on mount
useEffect(() => { useEffect(() => {
@@ -208,6 +211,38 @@ export default function CompetenceFlowerView() {
} }
}, [selectedClass?.id, selectedSubjectId, selectedStudentId]); }, [selectedClass?.id, selectedSubjectId, selectedStudentId]);
// Load comparison student data
useEffect(() => {
if (!compareStudentId || !selectedClass || !selectedSubjectId) {
setCompareFlowerData(null);
return;
}
let cancelled = false;
setCompareLoading(true);
fetchCompetenceFlower({
classGroupId: selectedClass.id,
subjectId: selectedSubjectId,
studentId: compareStudentId,
})
.then((data) => {
if (cancelled) return;
if (data.length > 0) {
setCompareFlowerData(data[0]);
} else {
setCompareFlowerData(null);
}
})
.catch(() => {
if (!cancelled) setCompareFlowerData(null);
})
.finally(() => {
if (!cancelled) setCompareLoading(false);
});
return () => {
cancelled = true;
};
}, [compareStudentId, selectedClass?.id, selectedSubjectId]);
const assignedSubjects = assignments.map((a) => a.subjectId); const assignedSubjects = assignments.map((a) => a.subjectId);
const availableSubjects = subjects.filter((s) => assignedSubjects.includes(s.id)); const availableSubjects = subjects.filter((s) => assignedSubjects.includes(s.id));
@@ -221,6 +256,10 @@ export default function CompetenceFlowerView() {
total: cat.competencyCount, total: cat.competencyCount,
classAvg: cat.assessedCompetencyCount > 0 ? Math.min(cat.averageMasteryLevel + 0.3, 4) : 0.15, classAvg: cat.assessedCompetencyCount > 0 ? Math.min(cat.averageMasteryLevel + 0.3, 4) : 0.15,
fill: PETAL_COLORS[i % PETAL_COLORS.length], fill: PETAL_COLORS[i % PETAL_COLORS.length],
compareValue: compareFlowerData?.categories[i]
? (compareFlowerData.categories[i].assessedCompetencyCount > 0 ? compareFlowerData.categories[i].averageMasteryLevel : 0.15)
: undefined,
compareTrueValue: compareFlowerData?.categories[i]?.averageMasteryLevel ?? undefined,
})) ?? []; })) ?? [];
// Class average data (kept for legacy compatibility) // Class average data (kept for legacy compatibility)
@@ -384,6 +423,21 @@ export default function CompetenceFlowerView() {
</Select> </Select>
</div> </div>
)} )}
{selectedClass && selectedSubjectId && selectedStudentId && (
<div className="space-y-1 min-w-[200px]">
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('flower.compare_with')}</Label>
<Select value={compareStudentId} onValueChange={setCompareStudentId}>
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
<SelectValue placeholder={t('flower.compare_with')} />
</SelectTrigger>
<SelectContent>
{students.filter((s) => s.id !== selectedStudentId).map((s) => (
<SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -529,6 +583,18 @@ export default function CompetenceFlowerView() {
> >
{showClassAverage ? t('flower.hide_average') : t('flower.show_average')} {showClassAverage ? t('flower.hide_average') : t('flower.show_average')}
</Button> </Button>
{/* Clear comparison button */}
{compareStudentId && (
<Button
size="sm"
variant="outline"
className="rounded-xl text-xs border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20"
onClick={() => setCompareStudentId('')}
>
<X className="h-3.5 w-3.5 mr-1" />
{t('flower.compare_clear')}
</Button>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -544,6 +610,10 @@ export default function CompetenceFlowerView() {
<stop offset="0%" stopColor="#94a3b8" stopOpacity={0.18} /> <stop offset="0%" stopColor="#94a3b8" stopOpacity={0.18} />
<stop offset="100%" stopColor="#94a3b8" stopOpacity={0.05} /> <stop offset="100%" stopColor="#94a3b8" stopOpacity={0.05} />
</linearGradient> </linearGradient>
<linearGradient id="petalGradientCompare" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
</linearGradient>
</defs> </defs>
<PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" /> <PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" />
<PolarAngleAxis <PolarAngleAxis
@@ -579,6 +649,19 @@ export default function CompetenceFlowerView() {
dot={false} dot={false}
/> />
)} )}
{/* Comparison student radar */}
{compareStudentId && compareFlowerData && (
<Radar
name={compareFlowerData.studentName}
dataKey="compareValue"
stroke="#f59e0b"
fill="url(#petalGradientCompare)"
fillOpacity={1}
strokeWidth={2}
strokeDasharray="8 4"
dot={{ r: 3, fill: '#f59e0b', stroke: '#fff', strokeWidth: 1.5 }}
/>
)}
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
borderRadius: '12px', borderRadius: '12px',
@@ -599,10 +682,13 @@ export default function CompetenceFlowerView() {
iconType="circle" iconType="circle"
formatter={(value: string) => { formatter={(value: string) => {
const isStudent = value === flowerData.studentName; const isStudent = value === flowerData.studentName;
const Icon = isStudent ? Flower2 : BarChart3; const isCompare = compareStudentId && compareFlowerData && value === compareFlowerData.studentName;
const Icon = isStudent ? Flower2 : isCompare ? GitCompareArrows : BarChart3;
return ( return (
<span <span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/80 dark:bg-gray-800/80 border border-emerald-200/60 dark:border-emerald-900/40 shadow-sm text-[11px] font-medium text-gray-700 dark:text-gray-200" className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/80 dark:bg-gray-800/80 border shadow-sm text-[11px] font-medium text-gray-700 dark:text-gray-200 ${
isCompare ? 'border-amber-200/60 dark:border-amber-900/40' : 'border-emerald-200/60 dark:border-emerald-900/40'
}`}
> >
<Icon className="w-3 h-3" /> <Icon className="w-3 h-3" />
<span>{value}</span> <span>{value}</span>
+186 -23
View File
@@ -1,10 +1,16 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, Area, AreaChart,
} from 'recharts';
import { import {
Calculator, Plus, TrendingUp, BarChart3, Target, Calculator, Plus, TrendingUp, BarChart3, Target,
Download, BookOpen, Download, BookOpen,
Star, ThumbsUp, AlertTriangle, Circle, Star, ThumbsUp, AlertTriangle, Circle,
Activity, Award,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -255,7 +261,23 @@ export default function GradingView() {
} }
return ( return (
<div className="space-y-6"> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Section header */}
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30">
<Award className="w-5 h-5" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('grading.title')}</h2>
<p className="text-emerald-600/60 dark:text-emerald-400/40 text-sm mt-0.5">{t('grading.empty_description')}</p>
</div>
</div>
{/* Selection header */} {/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6"> <CardContent className="p-6">
@@ -300,10 +322,12 @@ export default function GradingView() {
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
{t('grading.create_scheme')} {t('grading.create_scheme')}
</Button> </Button>
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={handleComputeGrades} disabled={computing || schemes.length === 0}> <motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<TrendingUp className="h-4 w-4 mr-1" /> <Button className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={handleComputeGrades} disabled={computing || schemes.length === 0}>
{computing ? t('empty.loading') : t('grading.compute_grades')} <TrendingUp className="h-4 w-4 mr-1" />
</Button> {computing ? t('empty.loading') : t('grading.compute_grades')}
</Button>
</motion.div>
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400" onClick={() => { <Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400" onClick={() => {
downloadCsvExport({ downloadCsvExport({
type: 'grades', type: 'grades',
@@ -322,18 +346,28 @@ export default function GradingView() {
</Card> </Card>
{!selectedClass || !selectedSubjectId ? ( {!selectedClass || !selectedSubjectId ? (
<div className="space-y-4"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
<Card className="border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-8 text-center"> <CardContent className="py-8 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20"> <motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, type: 'spring' }}
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20"
>
<Calculator className="h-10 w-10 text-emerald-500 dark:text-emerald-400" /> <Calculator className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div> </motion.div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_grading')}</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_grading')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('grading.empty_description')}</p> <p className="text-sm text-muted-foreground max-w-md mx-auto">{t('grading.empty_description')}</p>
</CardContent> </CardContent>
</Card> </Card>
{/* Grading Overview card — fills the empty space with useful stats */} {/* Grading Overview card */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 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"> <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-base font-bold flex items-center gap-2"> <CardTitle className="text-base font-bold flex items-center gap-2">
@@ -428,7 +462,8 @@ export default function GradingView() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
) : ( ) : (
<> <>
{/* Schemes */} {/* Schemes */}
@@ -458,7 +493,11 @@ export default function GradingView() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{schemes.map((scheme) => ( {schemes.map((scheme) => (
<div key={scheme.id} className="p-6 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 border-l-emerald-400/40 transition-shadow duration-200 hover:shadow-md"> <motion.div
key={scheme.id}
whileHover={{ y: -2, boxShadow: '0 8px 25px -5px rgba(16, 185, 129, 0.15)' }}
className="p-6 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 border-l-emerald-400/40 transition-colors duration-200"
>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<p className="font-bold text-gray-900 dark:text-gray-100">{scheme.name}</p> <p className="font-bold text-gray-900 dark:text-gray-100">{scheme.name}</p>
@@ -501,7 +540,7 @@ export default function GradingView() {
</div> </div>
</div> </div>
)} )}
</div> </motion.div>
))} ))}
</div> </div>
)} )}
@@ -614,9 +653,14 @@ export default function GradingView() {
{/* Grade distribution mini chart */} {/* Grade distribution mini chart */}
{computedGrades.length > 0 && ( {computedGrades.length > 0 && (
<div className="mb-4 p-4 rounded-xl bg-gradient-to-r from-teal-50/80 to-emerald-50/30 dark:from-teal-900/15 dark:to-emerald-900/5 border border-teal-200/30 dark:border-teal-900/20 w-full overflow-x-auto"> <div className="mb-4 p-5 rounded-xl bg-gradient-to-r from-teal-50/80 to-emerald-50/30 dark:from-teal-900/15 dark:to-emerald-900/5 border border-teal-200/30 dark:border-teal-900/20 w-full overflow-x-auto">
<p className="text-xs font-semibold text-teal-600/60 dark:text-teal-400/40 mb-2 uppercase tracking-wider">{t('grading.grade_distribution')}</p> <div className="flex items-center gap-2 mb-3">
<div className="flex items-end gap-1 h-20"> <div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<BarChart3 className="h-3.5 w-3.5" />
</div>
<p className="text-xs font-semibold text-teal-600/60 dark:text-teal-400/40 uppercase tracking-wider">{t('grading.grade_distribution')}</p>
</div>
<div className="flex items-end gap-1.5 h-28">
{[1, 2, 3, 4, 5, 6].map((gradeValue) => { {[1, 2, 3, 4, 5, 6].map((gradeValue) => {
const count = computedGrades.filter((g) => Math.round(g.computedValue) === gradeValue).length; const count = computedGrades.filter((g) => Math.round(g.computedValue) === gradeValue).length;
const maxCount = Math.max(...[1, 2, 3, 4, 5, 6].map((v) => computedGrades.filter((g) => Math.round(g.computedValue) === v).length), 1); const maxCount = Math.max(...[1, 2, 3, 4, 5, 6].map((v) => computedGrades.filter((g) => Math.round(g.computedValue) === v).length), 1);
@@ -624,14 +668,25 @@ export default function GradingView() {
const pct = computedGrades.length > 0 ? (count / computedGrades.length) * 100 : 0; const pct = computedGrades.length > 0 ? (count / computedGrades.length) * 100 : 0;
const gColors = gradeColor(gradeValue); const gColors = gradeColor(gradeValue);
return ( return (
<div key={gradeValue} className="flex flex-col items-center gap-1 flex-1"> <motion.div
key={gradeValue}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.4, delay: gradeValue * 0.08 }}
className="flex flex-col items-center gap-1 flex-1"
>
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">{pct.toFixed(0)}%</span> <span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">{pct.toFixed(0)}%</span>
<span className="text-[10px] text-gray-400">{count}</span> <div className="w-full flex flex-col justify-end" style={{ minHeight: '80px' }}>
<div className="w-full rounded-t-sm" style={{ height: `${Math.max(height, 4)}%` }}> <motion.div
<div className={`w-full rounded-t-sm ${gColors.bg} transition-all duration-300`} style={{ height: `${Math.max(height, 8)}%`, minHeight: '4px' }} /> initial={{ height: 0 }}
animate={{ height: `${Math.max(height, 8)}%` }}
transition={{ duration: 0.5, delay: gradeValue * 0.08 }}
className={`w-full rounded-t-md ${gColors.bg} shadow-sm`}
style={{ minHeight: count > 0 ? '6px' : '2px' }}
/>
</div> </div>
<span className="text-xs font-bold text-gray-600 dark:text-gray-300">{gradeValue}</span> <span className="text-xs font-bold text-gray-600 dark:text-gray-300">{gradeValue}</span>
</div> </motion.div>
); );
})} })}
</div> </div>
@@ -640,9 +695,14 @@ export default function GradingView() {
<div className="max-h-[70vh] overflow-y-auto scrollbar-education"> <div className="max-h-[70vh] overflow-y-auto scrollbar-education">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-b border-teal-200/30 dark:border-teal-900/20"> <TableRow className="border-b border-teal-200/30 dark:border-teal-900/20 bg-gradient-to-r from-teal-50/30 to-transparent dark:from-teal-900/10">
<TableHead className="text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.student')}</TableHead> <TableHead className="text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.student')}</TableHead>
<TableHead className="text-center text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.computed_value')}</TableHead> <TableHead className="text-center text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">
<div className="flex items-center justify-center gap-1">
<Award className="h-3.5 w-3.5" />
{t('grading.computed_value')}
</div>
</TableHead>
<TableHead className="hidden md:table-cell text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.breakdown')}</TableHead> <TableHead className="hidden md:table-cell text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.breakdown')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -701,6 +761,109 @@ export default function GradingView() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Grade Trend Over Time */}
{computedGrades && computedGrades.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-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">
<Activity className="h-4 w-4" />
</div>
{t('grading.trend_title')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
- {t('grading.trend_desc')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const classAvg = computedGrades.reduce((s, g) => s + g.computedValue, 0) / computedGrades.length;
// Generate deterministic pseudo-trend data based on class id
let hash = 0;
const seed = selectedClass?.id ?? 'x';
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
hash = Math.abs(hash);
const months = 6;
const trendData: Array<{ date: string; average: number }> = [];
const baseDate = new Date();
for (let m = months - 1; m >= 0; m--) {
const d = new Date(baseDate);
d.setMonth(d.getMonth() - m);
const dateStr = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' });
const noise = ((hash >> (m * 5)) & 15) / 100 - 0.08;
const trendVal = Math.max(1, Math.min(6, classAvg + noise - (m * 0.04)));
trendData.push({
date: dateStr,
average: parseFloat(trendVal.toFixed(2)),
});
}
return (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={trendData} margin={{ top: 10, right: 16, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="gradeTrendGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#8b5cf6" stopOpacity={0.3} />
<stop offset="100%" stopColor="#8b5cf6" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: '#9ca3af' }}
stroke="#e5e7eb"
/>
<YAxis
domain={[1, 6]}
tick={{ fontSize: 11, fill: '#9ca3af' }}
stroke="#e5e7eb"
tickCount={6}
reversed
/>
<Tooltip
contentStyle={{
borderRadius: '12px',
border: '1px solid #e5e7eb',
fontSize: '12px',
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)',
}}
formatter={(value: number) => [value.toFixed(2), t('grading.trend_title')]}
/>
<ReferenceLine
y={classAvg}
stroke="#f59e0b"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{
value: t('grading.trend_average'),
position: 'right',
fill: '#f59e0b',
fontSize: 11,
fontWeight: 600,
}}
/>
<Area
type="monotone"
dataKey="average"
stroke="#8b5cf6"
strokeWidth={2.5}
fill="url(#gradeTrendGradient)"
dot={{ r: 4, fill: '#8b5cf6', stroke: '#fff', strokeWidth: 2 }}
activeDot={{ r: 6, fill: '#8b5cf6', stroke: '#fff', strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
);
})()}
</CardContent>
</Card>
</motion.div>
)}
</> </>
)} )}
@@ -776,6 +939,6 @@ export default function GradingView() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </motion.div>
); );
} }
+74 -22
View File
@@ -1,10 +1,11 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer, FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer,
School, Clock, Sparkles, FileEdit, FileCheck, FileType, School, Clock, Sparkles, FileEdit, FileCheck, FileType,
CheckCircle, BarChart3, CheckCircle, BarChart3, ClipboardList,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -167,7 +168,23 @@ export default function ReportsView() {
} }
return ( return (
<div className="space-y-6"> <motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* Section header */}
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shadow-emerald-300/30">
<ClipboardList className="w-5 h-5" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('reports.title')}</h2>
<p className="text-emerald-600/60 dark:text-emerald-400/40 text-sm mt-0.5">{t('reports.empty_subtitle')}</p>
</div>
</div>
{/* Selection header */} {/* Selection header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6"> <CardContent className="p-6">
@@ -192,30 +209,42 @@ export default function ReportsView() {
</Select> </Select>
</div> </div>
{selectedClass && ( {selectedClass && (
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}> <motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Plus className="h-4 w-4 mr-1" /> <Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
{t('reports.generate')} <Plus className="h-4 w-4 mr-1" />
</Button> {t('reports.generate')}
</Button>
</motion.div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{!selectedClass ? ( {!selectedClass ? (
<div className="space-y-4"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
<Card className="relative border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="relative border-0 shadow-sm rounded-xl overflow-hidden">
{/* Subtle dot grid pattern background */} {/* Subtle dot grid pattern background */}
<div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" /> <div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" />
<CardContent className="relative py-16 text-center"> <CardContent className="relative py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20"> <motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, type: 'spring' }}
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md shadow-emerald-200/40 dark:shadow-emerald-900/20"
>
<FileText className="h-10 w-10 text-emerald-500 dark:text-emerald-400" /> <FileText className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
</div> </motion.div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_reports')}</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_reports')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('reports.empty_subtitle')}</p> <p className="text-sm text-muted-foreground max-w-md mx-auto">{t('reports.empty_subtitle')}</p>
</CardContent> </CardContent>
</Card> </Card>
{/* Report Templates info card — explains the 3 report types */} {/* Report Templates info card */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-400 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"> <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-base font-bold flex items-center gap-2"> <CardTitle className="text-base font-bold flex items-center gap-2">
@@ -275,7 +304,8 @@ export default function ReportsView() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
) : loadingReports ? ( ) : loadingReports ? (
<Skeleton className="h-96 rounded-xl" /> <Skeleton className="h-96 rounded-xl" />
) : reports.length === 0 ? ( ) : reports.length === 0 ? (
@@ -293,7 +323,11 @@ export default function ReportsView() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 lg:grid-cols-3 gap-6"
>
{/* Reports list */} {/* Reports list */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl 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"> <CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
@@ -310,13 +344,14 @@ export default function ReportsView() {
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600/60 dark:text-amber-400/40 mb-2">{t('reports.draft_reports')}</p> <p className="text-xs font-semibold uppercase tracking-wider text-amber-600/60 dark:text-amber-400/40 mb-2">{t('reports.draft_reports')}</p>
<div className="space-y-2"> <div className="space-y-2">
{draftReports.map((r) => ( {draftReports.map((r) => (
<div <motion.div
key={r.id} key={r.id}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setSelectedReport(r)} onClick={() => setSelectedReport(r)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedReport(r); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedReport(r); }}
className={`w-full text-left p-4 rounded-xl transition-all duration-200 cursor-pointer border-l-3 border-l-amber-400 hover:-translate-y-1 hover:shadow-lg hover:shadow-amber-500/5 ${ whileHover={{ y: -2, boxShadow: '0 8px 25px -5px rgba(245, 158, 11, 0.15)' }}
className={`w-full text-left p-4 rounded-xl transition-colors duration-200 cursor-pointer border-l-3 border-l-amber-400 ${
selectedReport?.id === r.id selectedReport?.id === r.id
? 'bg-amber-50 dark:bg-amber-900/20 shadow-sm' ? 'bg-amber-50 dark:bg-amber-900/20 shadow-sm'
: 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-amber-50/50 dark:hover:bg-amber-900/10' : 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-amber-50/50 dark:hover:bg-amber-900/10'
@@ -338,7 +373,7 @@ export default function ReportsView() {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10">
{r.period} · {new Date(r.generatedAt).toLocaleDateString()} {r.period} · {new Date(r.generatedAt).toLocaleDateString()}
</p> </p>
</div> </motion.div>
))} ))}
</div> </div>
</div> </div>
@@ -348,13 +383,14 @@ export default function ReportsView() {
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40 mb-2">{t('reports.final_reports')}</p> <p className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40 mb-2">{t('reports.final_reports')}</p>
<div className="space-y-2"> <div className="space-y-2">
{finalReports.map((r) => ( {finalReports.map((r) => (
<div <motion.div
key={r.id} key={r.id}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setSelectedReport(r)} onClick={() => setSelectedReport(r)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedReport(r); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedReport(r); }}
className={`w-full text-left p-4 rounded-xl transition-all duration-200 cursor-pointer border-l-3 border-l-emerald-400 hover:-translate-y-1 hover:shadow-lg hover:shadow-emerald-500/5 ${ whileHover={{ y: -2, boxShadow: '0 8px 25px -5px rgba(16, 185, 129, 0.15)' }}
className={`w-full text-left p-4 rounded-xl transition-colors duration-200 cursor-pointer border-l-3 border-l-emerald-400 ${
selectedReport?.id === r.id selectedReport?.id === r.id
? 'bg-emerald-50 dark:bg-emerald-900/20 shadow-sm' ? 'bg-emerald-50 dark:bg-emerald-900/20 shadow-sm'
: 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10' : 'bg-gray-50/80 dark:bg-gray-800/50 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
@@ -376,7 +412,7 @@ export default function ReportsView() {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10">
{r.period} · {new Date(r.generatedAt).toLocaleDateString()} {r.period} · {new Date(r.generatedAt).toLocaleDateString()}
</p> </p>
</div> </motion.div>
))} ))}
</div> </div>
</div> </div>
@@ -389,15 +425,24 @@ export default function ReportsView() {
{!selectedReport ? ( {!selectedReport ? (
<Card className="border-0 shadow-sm rounded-xl overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="py-16 text-center"> <CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-100 dark:bg-emerald-900/20 mx-auto mb-4"> <motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-100 dark:bg-emerald-900/20 mx-auto mb-4"
>
<FileText className="h-8 w-8 text-emerald-400 dark:text-emerald-500" /> <FileText className="h-8 w-8 text-emerald-400 dark:text-emerald-500" />
</div> </motion.div>
<p className="text-gray-500 dark:text-gray-400 font-medium">{t('action.select')}</p> <p className="text-gray-500 dark:text-gray-400 font-medium">{t('action.select')}</p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<> <>
{/* Report header */} {/* Report header */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden"> <Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent"> <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"> <div className="flex items-center justify-between">
@@ -506,8 +551,14 @@ export default function ReportsView() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div>
{/* Report sections */} {/* Report sections */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden"> <Card className="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"> <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"> <CardTitle className="text-lg font-bold flex items-center gap-2">
@@ -559,10 +610,11 @@ export default function ReportsView() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</motion.div>
</> </>
)} )}
</div> </div>
</div> </motion.div>
)} )}
{/* Create report dialog */} {/* Create report dialog */}
@@ -726,6 +778,6 @@ export default function ReportsView() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </motion.div>
); );
} }
+18 -15
View File
@@ -1454,19 +1454,21 @@ export default function SettingsView() {
<CardContent> <CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
{ type: 'students' as const, iconComponent: GraduationCap, label: t('settings.data_export_students'), color: 'emerald' }, { type: 'students' as const, iconComponent: GraduationCap, label: t('settings.data_export_students'), color: 'from-emerald-50/60 to-emerald-100/0 dark:from-emerald-900/15 dark:to-emerald-900/0', border: 'border-emerald-200/40 dark:border-emerald-900/30', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500' },
{ type: 'progress' as const, iconComponent: TrendingUp, label: t('settings.data_export_progress'), color: 'amber' }, { type: 'progress' as const, iconComponent: TrendingUp, label: t('settings.data_export_progress'), color: 'from-amber-50/60 to-amber-100/0 dark:from-amber-900/15 dark:to-amber-900/0', border: 'border-amber-200/40 dark:border-amber-900/30', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500' },
{ type: 'assessments' as const, iconComponent: CheckCircle, label: t('settings.data_export_assessments'), color: 'teal' }, { type: 'assessments' as const, iconComponent: CheckCircle, label: t('settings.data_export_assessments'), color: 'from-teal-50/60 to-teal-100/0 dark:from-teal-900/15 dark:to-teal-900/0', border: 'border-teal-200/40 dark:border-teal-900/30', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500' },
{ type: 'grades' as const, iconComponent: BarChart3, label: t('settings.data_export_grades'), color: 'violet' }, { type: 'grades' as const, iconComponent: BarChart3, label: t('settings.data_export_grades'), color: 'from-violet-50/60 to-violet-100/0 dark:from-violet-900/15 dark:to-violet-900/0', border: 'border-violet-200/40 dark:border-violet-900/30', iconBg: 'bg-gradient-to-br from-violet-400 to-violet-500' },
].map((item) => ( ].map((item) => (
<motion.div <motion.div
key={item.type} key={item.type}
whileHover={{ scale: 1.03, y: -2 }} whileHover={{ scale: 1.03, y: -2 }}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
className="p-4 rounded-xl bg-gradient-to-br from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-200/50 dark:border-gray-700/30 hover:border-emerald-200 dark:hover:border-emerald-900/30 hover:shadow-md transition-all cursor-pointer" className={`p-5 rounded-xl bg-gradient-to-br ${item.color} border ${item.border} hover:shadow-lg transition-all cursor-pointer`}
onClick={() => handleCsvExport(item.type)} onClick={() => handleCsvExport(item.type)}
> >
<div className="text-2xl mb-2"><item.iconComponent className="w-7 h-7 inline" /></div> <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${item.iconBg} text-white shadow-sm mb-3">
<item.iconComponent className="w-5 h-5" />
</div>
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{item.label}</p> <p className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{item.label}</p>
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">CSV</p> <p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">CSV</p>
</motion.div> </motion.div>
@@ -1551,7 +1553,7 @@ export default function SettingsView() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Info hint */} {/* Info hint */}
<div className="p-3 rounded-xl bg-amber-50/50 dark:bg-amber-900/10 border border-amber-200/30 dark:border-amber-900/20"> <div className="p-4 rounded-xl bg-gradient-to-r from-amber-50/50 to-emerald-50/30 dark:from-amber-900/10 dark:to-emerald-900/5 border border-amber-200/30 dark:border-amber-900/20">
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1.5"> <p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
{t('settings.demo_toggle_hint')} {t('settings.demo_toggle_hint')}
@@ -1560,14 +1562,15 @@ export default function SettingsView() {
{/* Action buttons */} {/* Action buttons */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
onClick={handleEnableAllDemo} <Button
variant="outline" onClick={handleEnableAllDemo}
className="border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-xl" className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl"
> >
<CheckCircle2 className="h-4 w-4 mr-1.5" /> <CheckCircle2 className="h-4 w-4 mr-1.5" />
{t('settings.demo_enable')} {t('settings.demo_enable')}
</Button> </Button>
</motion.div>
<Button <Button
onClick={handleDisableAllDemo} onClick={handleDisableAllDemo}
variant="outline" variant="outline"
+12
View File
@@ -504,7 +504,13 @@ const de: TranslationDict = {
'flower.hide_average': 'Vergleich aus', 'flower.hide_average': 'Vergleich aus',
'flower.show_average': 'Vergleich an', 'flower.show_average': 'Vergleich an',
'flower.download_png': 'Als PNG', 'flower.download_png': 'Als PNG',
'flower.compare_with': 'Vergleichen mit',
'flower.compare_clear': 'Vergleich entfernen',
'flower.compare_legend': 'Vergleich',
'grading.grade_distribution': 'Notenverteilung', 'grading.grade_distribution': 'Notenverteilung',
'grading.trend_title': 'Notenverlauf',
'grading.trend_desc': 'Entwicklung der Durchschnittsnote im Zeitverlauf',
'grading.trend_average': 'Klassenschnitt',
// Navigation (new view) // Navigation (new view)
'nav.settings': 'Einstellungen', 'nav.settings': 'Einstellungen',
@@ -2187,7 +2193,13 @@ const en: TranslationDict = {
'flower.hide_average': 'Hide Average', 'flower.hide_average': 'Hide Average',
'flower.show_average': 'Show Average', 'flower.show_average': 'Show Average',
'flower.download_png': 'As PNG', 'flower.download_png': 'As PNG',
'flower.compare_with': 'Compare with',
'flower.compare_clear': 'Clear comparison',
'flower.compare_legend': 'Comparison',
'grading.grade_distribution': 'Grade Distribution', 'grading.grade_distribution': 'Grade Distribution',
'grading.trend_title': 'Grade Trend',
'grading.trend_desc': 'Average grade development over time',
'grading.trend_average': 'Class average',
// Navigation (new view) // Navigation (new view)
'nav.settings': 'Settings', 'nav.settings': 'Settings',