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:
1 parent
3d0d518bb9
commit
7f0810c88f
28 files changed
+10264
-127
No files matched your search
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user