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);
if (!data || data.total === 0) return 'bg-gray-100 dark:bg-gray-800';
const rate = data.present / data.total;
if (rate >= 0.9) return 'bg-emerald-500 dark:bg-emerald-600';
if (rate >= 0.7) return 'bg-emerald-400 dark:bg-emerald-500';
if (rate >= 0.5) return 'bg-emerald-300 dark:bg-emerald-700';
if (rate >= 0.3) return 'bg-emerald-200 dark:bg-emerald-800';
return 'bg-rose-300 dark:bg-rose-700';
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-gradient-to-br from-emerald-400 to-teal-500 dark:from-emerald-500 dark:to-emerald-600';
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-gradient-to-br from-amber-200 to-amber-300 dark:from-emerald-800 dark:to-emerald-900';
return 'bg-gradient-to-br from-rose-300 to-rose-400 dark:from-rose-700 dark:to-rose-800';
}
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">
<CardHeader className="pb-2">
<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 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">
<CalendarCheck className="h-5 w-5 text-emerald-500" />
{t('attendance.calendar')} — {monthNames[month]} {year}
@@ -290,7 +290,7 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
<div
className={`aspect-square rounded-sm flex items-center justify-center text-xs
${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 && (
@@ -323,11 +323,11 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
<span>{t('attendance.absent')}</span>
<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-rose-300 dark:bg-rose-700" />
<div className="w-3 h-3 rounded-sm bg-emerald-200 dark:bg-emerald-800" />
<div className="w-3 h-3 rounded-sm bg-emerald-300 dark:bg-emerald-700" />
<div className="w-3 h-3 rounded-sm bg-emerald-400 dark:bg-emerald-500" />
<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-rose-300 to-rose-400 dark:from-rose-700 dark:to-rose-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-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-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-gradient-to-br from-emerald-500 to-emerald-600 dark:from-emerald-500 dark:to-emerald-600" />
</div>
<span>{t('attendance.present')}</span>
</div>
@@ -401,14 +401,15 @@ function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
return (
<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
key={card.label}
initial={{ opacity: 0, y: 20 }}
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">
<div className="flex items-center gap-2 mb-2">
<card.icon className="w-4 h-4" />
@@ -551,7 +552,7 @@ function SessionDetail({
size="sm"
onClick={handleSaveAll}
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 ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
@@ -604,7 +605,7 @@ function SessionDetail({
].map((s) => (
<div
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-xs text-muted-foreground">{s.label}</div>
@@ -912,7 +913,7 @@ export default function AttendanceView() {
<CalendarCheck className="h-8 w-8 text-emerald-500" />
{t('attendance.title')}
</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 className="flex items-center gap-3">
@@ -936,13 +937,15 @@ export default function AttendanceView() {
{/* New session button */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white 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')}
</Button>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<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 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')}
</Button>
</motion.div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
@@ -986,7 +989,7 @@ export default function AttendanceView() {
<Button
onClick={handleCreateSession}
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 ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
@@ -1104,14 +1107,14 @@ export default function AttendanceView() {
{/* Content */}
{currentClassId && !loading && (
<Tabs defaultValue="sessions" className="space-y-4">
<TabsList className="bg-gray-100 dark:bg-gray-800">
<TabsTrigger value="sessions" className="data-[state=active]:bg-emerald-100 data-[state=active]:dark:bg-emerald-900">
<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="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.status')}
</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')}
</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')}
</TabsTrigger>
</TabsList>
+89 -3
View File
@@ -19,7 +19,7 @@ import {
} from 'recharts';
import {
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,
Sprout, Leaf, TreePine, Trees, Star, BarChart3, Ruler, Compass, Triangle, Calculator as Abacus,
LucideIcon,
@@ -141,6 +141,9 @@ export default function CompetenceFlowerView() {
const [showClassAverage, setShowClassAverage] = useState(true);
const [primaryColor, setPrimaryColor] = useState('#10b981');
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
useEffect(() => {
@@ -208,6 +211,38 @@ export default function CompetenceFlowerView() {
}
}, [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 availableSubjects = subjects.filter((s) => assignedSubjects.includes(s.id));
@@ -221,6 +256,10 @@ export default function CompetenceFlowerView() {
total: cat.competencyCount,
classAvg: cat.assessedCompetencyCount > 0 ? Math.min(cat.averageMasteryLevel + 0.3, 4) : 0.15,
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)
@@ -384,6 +423,21 @@ export default function CompetenceFlowerView() {
</Select>
</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>
</CardContent>
</Card>
@@ -529,6 +583,18 @@ export default function CompetenceFlowerView() {
>
{showClassAverage ? t('flower.hide_average') : t('flower.show_average')}
</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>
</CardHeader>
<CardContent>
@@ -544,6 +610,10 @@ export default function CompetenceFlowerView() {
<stop offset="0%" stopColor="#94a3b8" stopOpacity={0.18} />
<stop offset="100%" stopColor="#94a3b8" stopOpacity={0.05} />
</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>
<PolarGrid stroke="#e5e7eb" strokeDasharray="3 3" />
<PolarAngleAxis
@@ -579,6 +649,19 @@ export default function CompetenceFlowerView() {
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
contentStyle={{
borderRadius: '12px',
@@ -599,10 +682,13 @@ export default function CompetenceFlowerView() {
iconType="circle"
formatter={(value: string) => {
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 (
<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" />
<span>{value}</span>
+186 -23
View File
@@ -1,10 +1,16 @@
'use client';
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 {
Calculator, Plus, TrendingUp, BarChart3, Target,
Download, BookOpen,
Star, ThumbsUp, AlertTriangle, Circle,
Activity, Award,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -255,7 +261,23 @@ export default function GradingView() {
}
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 */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
@@ -300,10 +322,12 @@ export default function GradingView() {
<Plus className="h-4 w-4 mr-1" />
{t('grading.create_scheme')}
</Button>
<Button variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={handleComputeGrades} disabled={computing || schemes.length === 0}>
<TrendingUp className="h-4 w-4 mr-1" />
{computing ? t('empty.loading') : t('grading.compute_grades')}
</Button>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<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}>
<TrendingUp className="h-4 w-4 mr-1" />
{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={() => {
downloadCsvExport({
type: 'grades',
@@ -322,18 +346,28 @@ export default function GradingView() {
</Card>
{!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">
<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" />
</div>
</motion.div>
<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>
</CardContent>
</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">
<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">
@@ -428,7 +462,8 @@ export default function GradingView() {
)}
</CardContent>
</Card>
</div>
</motion.div>
</motion.div>
) : (
<>
{/* Schemes */}
@@ -458,7 +493,11 @@ export default function GradingView() {
) : (
<div className="space-y-4">
{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>
<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>
</motion.div>
))}
</div>
)}
@@ -614,9 +653,14 @@ export default function GradingView() {
{/* Grade distribution mini chart */}
{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">
<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-end gap-1 h-20">
<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">
<div className="flex items-center gap-2 mb-3">
<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) => {
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);
@@ -624,14 +668,25 @@ export default function GradingView() {
const pct = computedGrades.length > 0 ? (count / computedGrades.length) * 100 : 0;
const gColors = gradeColor(gradeValue);
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-400">{count}</span>
<div className="w-full rounded-t-sm" style={{ height: `${Math.max(height, 4)}%` }}>
<div className={`w-full rounded-t-sm ${gColors.bg} transition-all duration-300`} style={{ height: `${Math.max(height, 8)}%`, minHeight: '4px' }} />
<div className="w-full flex flex-col justify-end" style={{ minHeight: '80px' }}>
<motion.div
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>
<span className="text-xs font-bold text-gray-600 dark:text-gray-300">{gradeValue}</span>
</div>
</motion.div>
);
})}
</div>
@@ -640,9 +695,14 @@ export default function GradingView() {
<div className="max-h-[70vh] overflow-y-auto scrollbar-education">
<Table>
<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-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>
</TableRow>
</TableHeader>
@@ -701,6 +761,109 @@ export default function GradingView() {
)}
</CardContent>
</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>
</DialogContent>
</Dialog>
</div>
</motion.div>
);
}
+74 -22
View File
@@ -1,10 +1,11 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FileText, Plus, Eye, Download, Check, PenLine, BookOpen, Printer,
School, Clock, Sparkles, FileEdit, FileCheck, FileType,
CheckCircle, BarChart3,
CheckCircle, BarChart3, ClipboardList,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -167,7 +168,23 @@ export default function ReportsView() {
}
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 */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
@@ -192,30 +209,42 @@ export default function ReportsView() {
</Select>
</div>
{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)}>
<Plus className="h-4 w-4 mr-1" />
{t('reports.generate')}
</Button>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<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)}>
<Plus className="h-4 w-4 mr-1" />
{t('reports.generate')}
</Button>
</motion.div>
)}
</div>
</CardContent>
</Card>
{!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">
{/* Subtle dot grid pattern background */}
<div className="absolute inset-0 bg-dots opacity-30 pointer-events-none" />
<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" />
</div>
</motion.div>
<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>
</CardContent>
</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">
<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">
@@ -275,7 +304,8 @@ export default function ReportsView() {
</div>
</CardContent>
</Card>
</div>
</motion.div>
</motion.div>
) : loadingReports ? (
<Skeleton className="h-96 rounded-xl" />
) : reports.length === 0 ? (
@@ -293,7 +323,11 @@ export default function ReportsView() {
</CardContent>
</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 */}
<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">
@@ -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>
<div className="space-y-2">
{draftReports.map((r) => (
<div
<motion.div
key={r.id}
role="button"
tabIndex={0}
onClick={() => 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
? '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'
@@ -338,7 +373,7 @@ export default function ReportsView() {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10">
{r.period} · {new Date(r.generatedAt).toLocaleDateString()}
</p>
</div>
</motion.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>
<div className="space-y-2">
{finalReports.map((r) => (
<div
<motion.div
key={r.id}
role="button"
tabIndex={0}
onClick={() => 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
? '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'
@@ -376,7 +412,7 @@ export default function ReportsView() {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-10">
{r.period} · {new Date(r.generatedAt).toLocaleDateString()}
</p>
</div>
</motion.div>
))}
</div>
</div>
@@ -389,15 +425,24 @@ export default function ReportsView() {
{!selectedReport ? (
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<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" />
</div>
</motion.div>
<p className="text-gray-500 dark:text-gray-400 font-medium">{t('action.select')}</p>
</CardContent>
</Card>
) : (
<>
{/* 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">
<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">
@@ -506,8 +551,14 @@ export default function ReportsView() {
</div>
</CardContent>
</Card>
</motion.div>
{/* 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">
<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">
@@ -559,10 +610,11 @@ export default function ReportsView() {
)}
</CardContent>
</Card>
</motion.div>
</>
)}
</div>
</div>
</motion.div>
)}
{/* Create report dialog */}
@@ -726,6 +778,6 @@ export default function ReportsView() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</motion.div>
);
}
+18 -15
View File
@@ -1454,19 +1454,21 @@ export default function SettingsView() {
<CardContent>
<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: 'progress' as const, iconComponent: TrendingUp, label: t('settings.data_export_progress'), color: 'amber' },
{ type: 'assessments' as const, iconComponent: CheckCircle, label: t('settings.data_export_assessments'), color: 'teal' },
{ type: 'grades' as const, iconComponent: BarChart3, label: t('settings.data_export_grades'), color: 'violet' },
{ 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: '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: '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: '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) => (
<motion.div
key={item.type}
whileHover={{ scale: 1.03, y: -2 }}
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)}
>
<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="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">CSV</p>
</motion.div>
@@ -1551,7 +1553,7 @@ export default function SettingsView() {
</CardHeader>
<CardContent className="space-y-4">
{/* 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">
<AlertTriangle className="h-3.5 w-3.5" />
{t('settings.demo_toggle_hint')}
@@ -1560,14 +1562,15 @@ export default function SettingsView() {
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
<Button
onClick={handleEnableAllDemo}
variant="outline"
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"
>
<CheckCircle2 className="h-4 w-4 mr-1.5" />
{t('settings.demo_enable')}
</Button>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
onClick={handleEnableAllDemo}
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" />
{t('settings.demo_enable')}
</Button>
</motion.div>
<Button
onClick={handleDisableAllDemo}
variant="outline"