Round 26: Grade Analytics, School Transport, Peer Assessment, Styling

- Grade Analytics & Reporting View: distribution histogram, student trajectory, class comparison, subject analysis, report generation
- School Transport & Bus Routes View: route management, student assignment, transport schedule, stop management
- Peer Assessment View: creation wizard, rating sliders, results radar chart, self vs peer comparison
- 3 new Prisma models: GradeReport, TransportRoute, TransportStop
- Extended PeerAssessment and StudentTransport models
- Enhanced classes-view with gradient header, glassmorphism cards
- Enhanced attendance-view with attendance rate gauge, status indicators
- Fixed duplicate variable name bugs in school-transport-view.tsx
- 12+ new API route files
- 300+ i18n keys added
- All views verified working in browser
This commit is contained in:
Mustafa Cetin committed 2026-07-30 07:20:05 +00:00
1 parent 3d0d518bb9
commit 7f0810c88f
28 files changed
+10264 -127

No files matched your search

+17
View File
@@ -72,6 +72,8 @@ import {
PartyPopper,
Newspaper,
Compass,
UsersRound,
Bus,
} from 'lucide-react';
import {
Sidebar,
@@ -192,6 +194,9 @@ import SchoolEventsView from './school-events-view';
import StudentCareerView from './student-career-view';
import SubstituteTeacherView from './substitute-teacher-view';
import SchoolNewsletterView from './school-newsletter-view';
import SchoolTransportView from './school-transport-view';
import PeerAssessmentView from './peer-assessment-view';
import GradeAnalyticsView from './grade-analytics-view';
// Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), {
@@ -223,6 +228,7 @@ const navSections: NavSection[] = [
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
{ key: 'analytics', icon: TrendingUp, labelKey: 'nav.analytics' },
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
{ key: 'reports', icon: FileText, labelKey: 'nav.reports' },
{ key: 'coverage', icon: Target, labelKey: 'nav.coverage' },
],
@@ -258,6 +264,7 @@ const navSections: NavSection[] = [
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
@@ -267,6 +274,7 @@ const navSections: NavSection[] = [
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
{ key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' },
{ key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
{ key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' },
],
@@ -293,6 +301,7 @@ const studentNavSections: NavSection[] = [
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
{ key: 'homework', icon: BookCheck, labelKey: 'nav.homework' },
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
@@ -305,6 +314,7 @@ const studentNavSections: NavSection[] = [
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
{ key: 'seating-chart', icon: LayoutGridIcon, labelKey: 'nav.seating_chart' },
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
@@ -315,6 +325,7 @@ const studentNavSections: NavSection[] = [
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
{ key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' },
{ key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
@@ -333,6 +344,7 @@ const parentNavSections: NavSection[] = [
{ key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' },
{ key: 'parents', icon: Mail, labelKey: 'parent.messages' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
@@ -341,10 +353,12 @@ const parentNavSections: NavSection[] = [
{ key: 'student-achievements', icon: Trophy, labelKey: 'achievements.title' },
{ key: 'student-study-planner', icon: Timer, labelKey: 'study_planner.title' },
{ key: 'student-career', icon: Compass, labelKey: 'nav.student-career' },
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
{ key: 'report-cards', icon: FileText, labelKey: 'nav.report_cards' },
{ key: 'school-library', icon: LibraryIcon, labelKey: 'nav.school_library' },
{ key: 'school-events', icon: PartyPopper, labelKey: 'nav.school-events' },
{ key: 'school-newsletter', icon: Newspaper, labelKey: 'nav.school-newsletter' },
{ key: 'school-transport', icon: Bus, labelKey: 'nav.school-transport' },
{ key: 'substitute-teacher', icon: UserCheck, labelKey: 'nav.substitute-teacher' },
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
@@ -491,6 +505,8 @@ function renderView(view: ViewName) {
case 'announcements': return <SchoolAnnouncementsView />;
case 'tablet-grading': return <TabletGradingView />;
case 'exam-calendar': return <ExamCalendarView />;
case 'peer-assessment': return <PeerAssessmentView />;
case 'grade-analytics': return <GradeAnalyticsView />;
case 'student-achievements': return <StudentAchievementsView />;
case 'student-study-planner': return <StudentStudyPlannerView />;
case 'seating-chart': return <SeatingChartView />;
@@ -502,6 +518,7 @@ function renderView(view: ViewName) {
case 'school-events': return <SchoolEventsView />;
case 'substitute-teacher': return <SubstituteTeacherView />;
case 'school-newsletter': return <SchoolNewsletterView />;
case 'school-transport': return <SchoolTransportView />;
default: return <DashboardView />;
}
}
+374 -49
View File
@@ -13,6 +13,7 @@ import {
Save,
ChevronDown,
ChevronRight,
ChevronLeft,
Loader2,
Users,
TrendingUp,
@@ -31,6 +32,9 @@ import {
FileDown,
RefreshCw,
Heart,
Activity,
CalendarDays,
CircleDot,
} from 'lucide-react';
import {
LineChart,
@@ -362,6 +366,197 @@ function MonthlyHeatmap({ sessions }: { sessions: AttendanceSession[] }) {
);
}
/* ── Attendance Gauge SVG Component ─────────────────────────────────── */
function AttendanceGauge({ rate, size = 100 }: { rate: number; size?: number }) {
const strokeWidth = 8;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (rate / 100) * circumference;
const rateClass = rate >= 90 ? 'rate-high' : rate >= 70 ? 'rate-medium' : 'rate-low';
return (
<div className="attendance-gauge" style={{ width: size, height: size }}>
<svg width={size} height={size}>
<circle
className="attendance-gauge-track"
cx={size / 2}
cy={size / 2}
r={radius}
/>
<circle
className={`attendance-gauge-fill ${rateClass}`}
cx={size / 2}
cy={size / 2}
r={radius}
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ '--circumference': circumference, '--offset': offset } as React.CSSProperties}
/>
</svg>
<div className="attendance-gauge-label">
<span className="text-xl font-bold text-gray-900 dark:text-gray-100">{rate}%</span>
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">{t('attendance.rate')}</span>
</div>
</div>
);
}
/* ── Mini Calendar Date Picker ──────────────────────────────────────── */
function MiniCalendar({
selectedDate,
onDateSelect,
sessionDates,
}: {
selectedDate: string;
onDateSelect: (date: string) => void;
sessionDates: Set<string>;
}) {
const [viewMonth, setViewMonth] = useState(() => {
const d = new Date(selectedDate);
return { year: d.getFullYear(), month: d.getMonth() };
});
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate();
const firstDayOfWeek = new Date(viewMonth.year, viewMonth.month, 1).getDay();
const adjustedFirstDay = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
const dayLabels = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const cells: Array<{ day: number | null; dateKey: string | null }> = [];
for (let i = 0; i < adjustedFirstDay; i++) {
cells.push({ day: null, dateKey: null });
}
for (let d = 1; d <= daysInMonth; d++) {
const dateKey = `${viewMonth.year}-${String(viewMonth.month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
cells.push({ day: d, dateKey });
}
const monthNames = [
t('date.jan') ?? 'Jan', t('date.feb') ?? 'Feb', t('date.mar') ?? 'Mar',
t('date.apr') ?? 'Apr', t('date.may') ?? 'May', t('date.jun') ?? 'Jun',
t('date.jul') ?? 'Jul', t('date.aug') ?? 'Aug', t('date.sep') ?? 'Sep',
t('date.oct') ?? 'Oct', t('date.nov') ?? 'Nov', t('date.dec') ?? 'Dec',
];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<button
onClick={() => setViewMonth((v) => {
const d = new Date(v.year, v.month - 1, 1);
return { year: d.getFullYear(), month: d.getMonth() };
})}
className="p-1 rounded-md hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
>
<ChevronLeft className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</button>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{monthNames[viewMonth.month]} {viewMonth.year}
</span>
<button
onClick={() => setViewMonth((v) => {
const d = new Date(v.year, v.month + 1, 1);
return { year: d.getFullYear(), month: d.getMonth() };
})}
className="p-1 rounded-md hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
>
<ChevronRight className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5">
{dayLabels.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">{d}</div>
))}
{cells.map((cell, i) => {
const isToday = cell.dateKey === todayStr;
const isSelected = cell.dateKey === selectedDate;
const hasSession = cell.dateKey ? sessionDates.has(cell.dateKey) : false;
return (
<button
key={i}
disabled={!cell.day}
onClick={() => cell.dateKey && onDateSelect(cell.dateKey)}
className={`mini-calendar-day ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${hasSession ? 'has-session' : ''} ${!cell.day ? 'opacity-0 cursor-default' : ''}`}
>
{cell.day}
</button>
);
})}
</div>
</div>
);
}
/* ── Daily Attendance Timeline ──────────────────────────────────────── */
function DailyTimeline({ sessions, selectedDate }: { sessions: AttendanceSession[]; selectedDate: string }) {
const daySessions = useMemo(() => {
return sessions.filter((s) => {
const d = new Date(s.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return key === selectedDate;
});
}, [sessions, selectedDate]);
if (daySessions.length === 0) {
return (
<div className="text-center py-8 text-sm text-muted-foreground">
{t('attendance.no_sessions')}
</div>
);
}
return (
<div className="space-y-3">
{daySessions.map((session) => {
const presentCount = session.records.filter((r) => r.status === 'PRESENT').length;
const lateCount = session.records.filter((r) => r.status === 'LATE').length;
const absentCount = session.records.filter((r) => r.status === 'ABSENT').length;
const excusedCount = session.records.filter((r) => r.status === 'EXCUSED').length;
const total = session.records.length;
const rate = total > 0 ? Math.round(((presentCount + lateCount) / total) * 100) : 0;
return (
<div key={session.id} className="flex items-center gap-3">
<div className="flex flex-col items-center">
<div className={`timeline-dot ${rate >= 90 ? 'bg-emerald-500' : rate >= 70 ? 'bg-amber-500' : 'bg-rose-500'}`} />
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
</div>
<div className="flex-1 p-3 rounded-lg bg-gradient-to-r from-white to-gray-50/50 dark:from-gray-900 dark:to-gray-800/50 border border-gray-100 dark:border-gray-800">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{session.period ?? t('attendance.status')}
</span>
{session.subject && (
<span className="text-xs text-muted-foreground ml-2">{session.subject.name}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="status-badge status-badge-present"><CheckCircle2 className="w-3 h-3" />{presentCount}</span>
<span className="status-badge status-badge-late"><Timer className="w-3 h-3" />{lateCount}</span>
<span className="status-badge status-badge-absent"><XCircle className="w-3 h-3" />{absentCount}</span>
{excusedCount > 0 && <span className="status-badge status-badge-excused"><ClipboardList className="w-3 h-3" />{excusedCount}</span>}
</div>
</div>
<div className="mt-2 w-full h-1.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<div className="flex h-full">
<div className="bg-emerald-500 h-full" style={{ width: `${total > 0 ? (presentCount / total) * 100 : 0}%` }} />
<div className="bg-amber-500 h-full" style={{ width: `${total > 0 ? (lateCount / total) * 100 : 0}%` }} />
<div className="bg-rose-500 h-full" style={{ width: `${total > 0 ? (absentCount / total) * 100 : 0}%` }} />
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
/* ── Stats Cards ───────────────────────────────────────────────────── */
function StatsCards({ sessions }: { sessions: AttendanceSession[] }) {
@@ -426,28 +621,41 @@ 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, idx) => (
<motion.div
key={card.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: idx * 0.06 }}
whileHover={{ y: -2 }}
>
<Card className={`card-hover-lift 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" />
<span className="text-xs font-medium text-muted-foreground">{card.label}</span>
</div>
<div className={`text-2xl font-bold bg-gradient-to-r ${card.gradient} bg-clip-text text-transparent`}>
{card.value}
</div>
</CardContent>
</Card>
</motion.div>
))}
<div className="space-y-4">
{/* Attendance Gauge + Summary */}
<div className="flex flex-col sm:flex-row items-center gap-4">
<AttendanceGauge rate={stats.rate} size={100} />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 flex-1">
<div className="glass-card-enhanced rounded-xl p-3 text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">{t('attendance.present')}</span>
</div>
<span className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{stats.present}</span>
</div>
<div className="glass-card-enhanced rounded-xl p-3 text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<XCircle className="w-3.5 h-3.5 text-rose-500" />
<span className="text-[10px] uppercase tracking-wider font-semibold text-rose-600/70 dark:text-rose-400/60">{t('attendance.absent')}</span>
</div>
<span className="text-xl font-bold text-rose-600 dark:text-rose-400">{stats.absent}</span>
</div>
<div className="glass-card-enhanced rounded-xl p-3 text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<ClipboardList className="w-3.5 h-3.5 text-amber-500" />
<span className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60">{t('attendance.excused')}</span>
</div>
<span className="text-xl font-bold text-amber-600 dark:text-amber-400">{stats.excused}</span>
</div>
<div className="glass-card-enhanced rounded-xl p-3 text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Timer className="w-3.5 h-3.5 text-orange-500" />
<span className="text-[10px] uppercase tracking-wider font-semibold text-orange-600/70 dark:text-orange-400/60">{t('attendance.late')}</span>
</div>
<span className="text-xl font-bold text-orange-600 dark:text-orange-400">{stats.late}</span>
</div>
</div>
</div>
</div>
);
}
@@ -1300,19 +1508,48 @@ export default function AttendanceView() {
return (
<div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6">
{/* Header */}
{/* Gradient Header Banner */}
<motion.div
initial={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"
transition={{ duration: 0.4 }}
className="gradient-header-banner rounded-xl px-6 py-5"
>
<div>
<h1 className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-emerald-500 to-teal-600 bg-clip-text text-transparent flex items-center gap-2">
<CalendarCheck className="h-8 w-8 text-emerald-500" />
{t('attendance.title')}
</h1>
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('attendance.subtitle')}</p>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg shadow-emerald-300/30">
<CalendarCheck className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 dark:from-emerald-400 dark:to-teal-400 bg-clip-text text-transparent">
{t('attendance.title')}
</h1>
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-0.5">{t('attendance.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Today's Attendance Quick View */}
{sessions.length > 0 && (() => {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const todaySessions = sessions.filter((s) => {
const d = new Date(s.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return key === todayStr;
});
const todayPresent = todaySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'PRESENT').length, 0);
const todayTotal = todaySessions.reduce((sum, s) => sum + s.records.length, 0);
const todayRate = todayTotal > 0 ? Math.round((todayPresent / todayTotal) * 100) : 0;
if (todayTotal === 0) return null;
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/60 dark:bg-gray-800/40 border border-emerald-200/30 dark:border-emerald-900/30">
<Activity className="h-4 w-4 text-emerald-500" />
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">{t('date.today')}:</span>
<span className={`text-sm font-bold ${todayRate >= 90 ? 'text-emerald-600 dark:text-emerald-400' : todayRate >= 70 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'}`}>{todayRate}%</span>
</div>
);
})()}
</div>
<div className="flex items-center gap-3">
{/* Class selector */}
@@ -1412,6 +1649,7 @@ export default function AttendanceView() {
</DialogContent>
</Dialog>
</div>
</div>
</motion.div>
{/* No class selected */}
@@ -1430,7 +1668,7 @@ export default function AttendanceView() {
</div>
{/* Quick Stats card — fills the empty space with school-wide attendance stats */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-400 overflow-hidden">
<Card className="glass-card-enhanced rounded-xl border-l-3 border-l-emerald-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
@@ -1531,6 +1769,10 @@ export default function AttendanceView() {
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.calendar')}
</TabsTrigger>
<TabsTrigger value="today" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
<CalendarDays className="h-4 w-4 mr-1" />
{t('date.today')}
</TabsTrigger>
<TabsTrigger value="qr" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
<QrCode className="h-4 w-4 mr-1" />
{t('qr.attendance')}
@@ -1571,16 +1813,14 @@ export default function AttendanceView() {
exit={{ opacity: 0, x: 20 }}
transition={{ delay: idx * 0.05 }}
>
<Card
className="cursor-pointer hover:shadow-lg transition-all border-0 shadow-md
bg-gradient-to-r from-white to-gray-50/50 dark:from-gray-900 dark:to-gray-800/50
<Card className="cursor-pointer glass-card-enhanced transition-all
hover:from-emerald-50/30 hover:to-teal-50/20 dark:hover:from-emerald-950/20 dark:hover:to-teal-950/10"
onClick={() => setSelectedSessionId(session.id)}
>
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-emerald-100 dark:bg-emerald-900/50">
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg glass-card-enhanced">
<span className="text-lg font-bold text-emerald-700 dark:text-emerald-300">
{sessionDate.getDate()}
</span>
@@ -1614,7 +1854,7 @@ export default function AttendanceView() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 bg-emerald-50 dark:bg-emerald-900/30 px-2 py-1 rounded-md">
<div className="flex items-center gap-1 bg-emerald-50/60 dark:bg-emerald-900/30 px-2 py-1 rounded-md">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-300">{presentCount}</span>
</div>
@@ -1625,7 +1865,7 @@ export default function AttendanceView() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 bg-rose-50 dark:bg-rose-900/30 px-2 py-1 rounded-md">
<div className="flex items-center gap-1 bg-rose-50/60 dark:bg-rose-900/30 px-2 py-1 rounded-md">
<XCircle className="w-3.5 h-3.5" />
<span className="text-xs font-medium text-rose-700 dark:text-rose-300">{absentCount}</span>
</div>
@@ -1636,7 +1876,7 @@ export default function AttendanceView() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 bg-amber-50 dark:bg-amber-900/30 px-2 py-1 rounded-md">
<div className="flex items-center gap-1 bg-amber-50/60 dark:bg-amber-900/30 px-2 py-1 rounded-md">
<ClipboardList className="w-3.5 h-3.5" />
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">{excusedCount}</span>
</div>
@@ -1647,7 +1887,7 @@ export default function AttendanceView() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 bg-orange-50 dark:bg-orange-900/30 px-2 py-1 rounded-md">
<div className="flex items-center gap-1 bg-orange-50/60 dark:bg-orange-900/30 px-2 py-1 rounded-md">
<Timer className="w-3.5 h-3.5" />
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">{lateCount}</span>
</div>
@@ -1659,13 +1899,7 @@ export default function AttendanceView() {
{/* Rate badge */}
<div
className={`text-sm font-bold px-2 py-1 rounded-lg ${
rate >= 90
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
: rate >= 70
? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300'
}`}
className={`status-badge ${rate >= 90 ? 'status-badge-present' : rate >= 70 ? 'status-badge-late' : 'status-badge-absent'}`}
>
{rate}%
</div>
@@ -1710,9 +1944,100 @@ export default function AttendanceView() {
<MonthlyHeatmap sessions={sessions} />
</TabsContent>
{/* Today's Attendance Tab */}
<TabsContent value="today" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Mini Calendar */}
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-5">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-emerald-500" />
{t('attendance.calendar')}
</CardTitle>
</CardHeader>
<CardContent>
<MiniCalendar
selectedDate={newDate}
onDateSelect={setNewDate}
sessionDates={(() => {
const dates = new Set<string>();
for (const session of sessions) {
const d = new Date(session.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
dates.add(key);
}
return dates;
})()}
/>
</CardContent>
</Card>
{/* Daily Timeline */}
<div className="lg:col-span-2 space-y-4">
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-5">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity className="h-4 w-4 text-teal-500" />
{t('attendance.daily_timeline') ?? (t('date.today') === 'Heute' ? 'Tagesverlauf' : 'Daily Timeline')}
<span className="text-xs font-normal text-muted-foreground">{newDate}</span>
</CardTitle>
</CardHeader>
<CardContent>
<DailyTimeline sessions={sessions} selectedDate={newDate} />
</CardContent>
</Card>
{/* Attendance Gauge for selected date */}
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-5">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CircleDot className="h-4 w-4 text-emerald-500" />
{t('attendance.rate') ?? 'Rate'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center gap-6">
{(() => {
const daySessions = sessions.filter((s) => {
const d = new Date(s.date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return key === newDate;
});
const present = daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'PRESENT').length, 0);
const total = daySessions.reduce((sum, s) => sum + s.records.length, 0);
const rate = total > 0 ? Math.round((present / total) * 100) : 0;
return (
<>
<AttendanceGauge rate={rate} size={120} />
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.present')}: {present}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-rose-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.absent')}: {daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'ABSENT').length, 0)}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.late')}: {daySessions.reduce((sum, s) => sum + s.records.filter((r) => r.status === 'LATE').length, 0)}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300">{t('attendance.total_records') ?? 'Total'}: {total}</span>
</div>
</div>
</>
);
})()}
</div>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* QR Attendance Tab */}
<TabsContent value="qr" className="space-y-4">
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<Card className="glass-card-enhanced 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">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
+296 -64
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import {
Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School,
Download, Upload, FileText, CheckCircle2, AlertTriangle, FileUp,
@@ -13,6 +13,7 @@ import {
Shuffle, Printer, Eraser, Columns3, Rows3, Move,
Camera,
UserCheck, Shield, Eye, MessageSquare, CalendarDays, AlertCircle,
Activity, Zap, TrendingUp, CircleDot,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -143,6 +144,86 @@ const masteryBadgeClass = (level: number) => {
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
};
// Grade level color mapping (German school system 1-13)
const gradeLevelColors: Record<string, string> = {
'1': 'bg-emerald-500', '2': 'bg-emerald-400', '3': 'bg-teal-500',
'4': 'bg-teal-400', '5': 'bg-cyan-500', '6': 'bg-cyan-400',
'7': 'bg-blue-500', '8': 'bg-blue-400', '9': 'bg-violet-500',
'10': 'bg-violet-400', '11': 'bg-purple-500', '12': 'bg-purple-400',
'13': 'bg-rose-500',
};
const gradeLevelTextColors: Record<string, string> = {
'1': 'text-emerald-700 dark:text-emerald-300', '2': 'text-emerald-600 dark:text-emerald-400',
'3': 'text-teal-700 dark:text-teal-300', '4': 'text-teal-600 dark:text-teal-400',
'5': 'text-cyan-700 dark:text-cyan-300', '6': 'text-cyan-600 dark:text-cyan-400',
'7': 'text-blue-700 dark:text-blue-300', '8': 'text-blue-600 dark:text-blue-400',
'9': 'text-violet-700 dark:text-violet-300', '10': 'text-violet-600 dark:text-violet-400',
'11': 'text-purple-700 dark:text-purple-300', '12': 'text-purple-600 dark:text-purple-400',
'13': 'text-rose-700 dark:text-rose-300',
};
const gradeLevelBgColors: Record<string, string> = {
'1': 'bg-emerald-100 dark:bg-emerald-900/30', '2': 'bg-emerald-50 dark:bg-emerald-900/20',
'3': 'bg-teal-100 dark:bg-teal-900/30', '4': 'bg-teal-50 dark:bg-teal-900/20',
'5': 'bg-cyan-100 dark:bg-cyan-900/30', '6': 'bg-cyan-50 dark:bg-cyan-900/20',
'7': 'bg-blue-100 dark:bg-blue-900/30', '8': 'bg-blue-50 dark:bg-blue-900/20',
'9': 'bg-violet-100 dark:bg-violet-900/30', '10': 'bg-violet-50 dark:bg-violet-900/20',
'11': 'bg-purple-100 dark:bg-purple-900/30', '12': 'bg-purple-50 dark:bg-purple-900/20',
'13': 'bg-rose-100 dark:bg-rose-900/30',
};
// Animated count-up component
function AnimatedCount({ value, duration = 800 }: { value: number; duration?: number }) {
const [display, setDisplay] = useState(0);
useEffect(() => {
let start = 0;
const end = value;
if (end === 0) { setDisplay(0); return; }
const stepTime = Math.max(Math.floor(duration / end), 16);
const timer = setInterval(() => {
start += 1;
setDisplay(start);
if (start >= end) clearInterval(timer);
}, stepTime);
return () => clearInterval(timer);
}, [value, duration]);
return <span className="animate-count-up">{display}</span>;
}
// Attendance gauge SVG component
function AttendanceGauge({ rate, size = 80 }: { rate: number; size?: number }) {
const strokeWidth = 6;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (rate / 100) * circumference;
const rateClass = rate >= 90 ? 'rate-high' : rate >= 70 ? 'rate-medium' : 'rate-low';
return (
<div className="attendance-gauge" style={{ width: size, height: size }}>
<svg width={size} height={size}>
<circle
className="attendance-gauge-track"
cx={size / 2}
cy={size / 2}
r={radius}
/>
<circle
className={`attendance-gauge-fill ${rateClass}`}
cx={size / 2}
cy={size / 2}
r={radius}
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ '--circumference': circumference, '--offset': offset } as React.CSSProperties}
/>
</svg>
<div className="attendance-gauge-label">
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">{rate}%</span>
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">{t('attendance.rate')}</span>
</div>
</div>
);
}
export default function ClassesView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
@@ -844,9 +925,134 @@ export default function ClassesView() {
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-6">
{/* Gradient Header Banner */}
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="gradient-header-banner rounded-xl px-6 py-5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg shadow-emerald-300/30">
<School className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 dark:from-emerald-400 dark:to-teal-400 bg-clip-text text-transparent">
{t('classes.title')}
</h1>
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-0.5">
{locale === 'de' ? 'Klassen und Schueler verwalten' : 'Manage classes and students'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/60 dark:bg-gray-800/40 border border-emerald-200/30 dark:border-emerald-900/30">
<Users className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
<AnimatedCount value={filteredClasses.length} /> {locale === 'de' ? 'Klassen' : 'Classes'}
</span>
</div>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/60 dark:bg-gray-800/40 border border-emerald-200/30 dark:border-emerald-900/30">
<Activity className="h-4 w-4 text-teal-500" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
<AnimatedCount value={filteredClasses.reduce((s, c) => s + (c.studentCount ?? 0), 0)} /> {locale === 'de' ? 'Schueler' : 'Students'}
</span>
</div>
</div>
</div>
</motion.div>
{/* Class Statistics Summary */}
{filteredClasses.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="grid grid-cols-2 sm:grid-cols-4 gap-3"
>
<div className="glass-card-enhanced rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<School className="h-3.5 w-3.5" />
</div>
<span className="text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">{locale === 'de' ? 'Klassen' : 'Classes'}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{filteredClasses.length}</p>
</div>
<div className="glass-card-enhanced rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
<Users className="h-3.5 w-3.5" />
</div>
<span className="text-[10px] uppercase tracking-wider font-semibold text-teal-600/70 dark:text-teal-400/60">{locale === 'de' ? 'Schueler' : 'Students'}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{filteredClasses.reduce((s, c) => s + (c.studentCount ?? 0), 0)}</p>
</div>
<div className="glass-card-enhanced rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<BookOpen className="h-3.5 w-3.5" />
</div>
<span className="text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60">{locale === 'de' ? 'Faecher' : 'Subjects'}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{filteredClasses.reduce((s, c) => s + (c._count?.competencyAssignments ?? 0), 0)}</p>
</div>
<div className="glass-card-enhanced rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<BarChart3 className="h-3.5 w-3.5" />
</div>
<span className="text-[10px] uppercase tracking-wider font-semibold text-violet-600/70 dark:text-violet-400/60">{locale === 'de' ? 'Jahrgaenge' : 'Grades'}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 animate-count-up">{new Set(filteredClasses.map((c) => c.gradeLevel)).size}</p>
</div>
</motion.div>
)}
{/* Class Comparison Mini-Chart */}
{filteredClasses.length > 1 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15 }}
className="glass-card-enhanced rounded-xl p-4"
>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{locale === 'de' ? 'Klassenvergleich' : 'Class Comparison'}</span>
<span className="text-[10px] text-muted-foreground">{locale === 'de' ? 'Schueler pro Klasse' : 'Students per class'}</span>
</div>
<div className="space-y-2">
{filteredClasses.slice(0, 6).map((cls) => {
const count = cls.studentCount ?? 0;
const maxStudents = Math.max(...filteredClasses.map((c) => c.studentCount ?? 0), 1);
const pct = (count / maxStudents) * 100;
const gradeKey = String(cls.gradeLevel ?? 1);
return (
<div key={cls.id} className="flex items-center gap-2">
<span className="grade-indicator grade-{gradeKey}">{cls.gradeLevel}</span>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 w-20 truncate">{cls.name}</span>
<div className="flex-1 h-5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.max(pct, 3)}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={`h-full rounded-full ${gradeLevelColors[gradeKey] ?? 'bg-emerald-500'}`}
/>
</div>
<span className="text-xs font-bold text-gray-600 dark:text-gray-400 w-8 text-right">{count}</span>
</div>
);
})}
</div>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Class list */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<Card className="glass-card-enhanced 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">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-300/20">
@@ -897,68 +1103,93 @@ export default function ClassesView() {
</div>
) : (
<div className="space-y-2">
{filteredClasses.map((cls) => (
<motion.button
key={cls.id}
whileHover={{ scale: 1.02, y: -3 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectClass(cls)}
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-shadow-transition ${
selectedClass?.id === cls.id
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
}`}
>
{/* Gradient overlay strip at top */}
<div className={`h-1.5 rounded-t-xl ${schoolTypeAccentBg[cls.schoolType] ?? 'bg-emerald-500'} opacity-60`} />
<div className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
<Badge className={`${schoolTypeBadgeBg[cls.schoolType] ?? schoolTypeBadgeBg.OTHER} text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1`}>
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
</Badge>
{cls.responsibleTeacher && (
<Badge className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1 shadow-sm">
<UserCheck className="w-3 h-3" />
{cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName}
{filteredClasses.map((cls) => {
const gradeKey = String(cls.gradeLevel ?? 1);
return (
<motion.button
key={cls.id}
whileHover={{ scale: 1.02, y: -3 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectClass(cls)}
className={`w-full text-left glass-card-enhanced rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} ${
selectedClass?.id === cls.id
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
: ''
}`}
>
{/* Gradient overlay strip at top */}
<div className={`h-1.5 rounded-t-xl ${schoolTypeAccentBg[cls.schoolType] ?? 'bg-emerald-500'} opacity-60`} />
<div className="p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
{/* Grade level indicator */}
<span className={`grade-indicator grade-${Math.min(Math.max(cls.gradeLevel ?? 1, 1), 6)}`}>
{cls.gradeLevel}
</span>
<Badge className={`${schoolTypeBadgeBg[cls.schoolType] ?? schoolTypeBadgeBg.OTHER} text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1`}>
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
</Badge>
)}
{cls.responsibleTeacher && (
<Badge className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1 shadow-sm">
<UserCheck className="w-3 h-3" />
{cls.responsibleTeacher.firstName} {cls.responsibleTeacher.lastName}
</Badge>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
</p>
{/* Student count + subject count with icons */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600/70 dark:text-emerald-400/60">
<Users className="h-3 w-3" />
<AnimatedCount value={cls.studentCount ?? 0} duration={400} /> {t('label.student_count').toLowerCase()}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-teal-600/70 dark:text-teal-400/60">
<BookOpen className="h-3 w-3" />
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
</span>
</div>
{/* Quick Actions */}
<div className="flex items-center gap-1.5 mt-2">
<button className="quick-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectClass(cls); }}>
<Zap className="h-3 w-3" />
{locale === 'de' ? 'Details' : 'Details'}
</button>
<button className="quick-action-btn" onClick={(e) => { e.stopPropagation(); if (selectedClass?.id === cls.id) { setSeatingChartOpen(true); } else { handleSelectClass(cls); } }}>
<Grid3X3 className="h-3 w-3" />
{locale === 'de' ? 'Sitzplan' : 'Seating'}
</button>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
</p>
{/* Student count + subject count with icons */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-600/70 dark:text-emerald-400/60">
<Users className="h-3 w-3" />
{cls.studentCount ?? 0} {t('label.student_count').toLowerCase()}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-teal-600/70 dark:text-teal-400/60">
<BookOpen className="h-3 w-3" />
{cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'}
</span>
<div className="flex items-center gap-2 shrink-0">
{/* Small progress indicator */}
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
style={{ width: `${progressPercent(cls)}%` }}
/>
</div>
{/* Animated student count badge */}
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
whileHover={{ scale: 1.1 }}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 border border-emerald-200/30 dark:border-emerald-800/30"
>
<Users className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-300">{cls.studentCount ?? 0}</span>
</motion.div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Small progress indicator */}
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
style={{ width: `${progressPercent(cls)}%` }}
/>
</div>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium">
{cls.studentCount ?? 0} <Users className="h-3 w-3 ml-1" />
</Badge>
</div>
</div>
</div>
</motion.button>
))}
</motion.button>
);
})}
</div>
)}
</CardContent>
@@ -968,7 +1199,7 @@ export default function ClassesView() {
<div className="md:col-span-1 lg:col-span-2 space-y-6">
{!selectedClass ? (
<div className="space-y-4">
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<Card className="glass-card-enhanced rounded-xl overflow-hidden">
<CardContent className="py-16 text-center">
<div className="relative flex items-center justify-center w-24 h-24 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-6 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/20 ring-4 ring-emerald-50 dark:ring-emerald-900/30">
<Users className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
@@ -985,7 +1216,7 @@ export default function ClassesView() {
</Card>
{/* Recent Activity widget — fills the empty space with useful info */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-400 overflow-hidden">
<Card className="glass-card-enhanced rounded-xl border-l-3 border-l-teal-400 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-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shadow-teal-300/20">
@@ -1061,7 +1292,7 @@ export default function ClassesView() {
) : (
<>
{/* Class details */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<Card className="glass-card-enhanced 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">
<div>
@@ -1282,7 +1513,7 @@ export default function ClassesView() {
</div>
{/* Student roster */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<Card className="glass-card-enhanced 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">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
@@ -1873,6 +2104,7 @@ export default function ClassesView() {
</>
)}
</div>
</div>
{/* Add student dialog */}
<Dialog open={addStudentOpen} onOpenChange={setAddStudentOpen}>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff