diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 361eca3..d09f7de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,6 +133,7 @@ model User { rewardRedemptions RewardRedemption[] rewardPoints RewardPoints[] studentAnswers StudentAnswer[] + student Student? } model SchoolYear { @@ -209,10 +210,12 @@ model Student { externalId String? // for import matching avatarUrl String? // student photo/avatar URL avatarInitials String? // computed from firstName + lastName first chars + userId String? @unique // optional link to User account (for student self-service) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? + user User? @relation(fields: [userId], references: [id]) school School @relation(fields: [schoolId], references: [id]) enrollments Enrollment[] learningProgressEntries LearningProgressEntry[] @@ -236,6 +239,9 @@ model Student { transports StudentTransport[] healthRecords HealthRecord[] studentBadges StudentBadge[] + goals StudentGoal[] + + @@index([userId]) } model Enrollment { @@ -1912,3 +1918,22 @@ model RewardPoints { @@index([source]) @@map("reward_points") } + +// ─── Student Goals (Student Portal) ───────────────────────────────── + +model StudentGoal { + id String @id @default(cuid()) + studentId String + title String + description String? + targetDate DateTime? + progress Int @default(0) + status String @default("active") // active, completed, abandoned + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + student Student @relation(fields: [studentId], references: [id]) + + @@index([studentId]) + @@index([status]) +} diff --git a/qa-screenshots/round19-auth-enhanced.png b/qa-screenshots/round19-auth-enhanced.png new file mode 100644 index 0000000..12ee396 Binary files /dev/null and b/qa-screenshots/round19-auth-enhanced.png differ diff --git a/qa-screenshots/round19-auth-enhanced2.png b/qa-screenshots/round19-auth-enhanced2.png new file mode 100644 index 0000000..ce1718c Binary files /dev/null and b/qa-screenshots/round19-auth-enhanced2.png differ diff --git a/qa-screenshots/round19-classes.png b/qa-screenshots/round19-classes.png new file mode 100644 index 0000000..61a3d64 Binary files /dev/null and b/qa-screenshots/round19-classes.png differ diff --git a/qa-screenshots/round19-dashboard-enhanced.png b/qa-screenshots/round19-dashboard-enhanced.png new file mode 100644 index 0000000..73ebc4e Binary files /dev/null and b/qa-screenshots/round19-dashboard-enhanced.png differ diff --git a/qa-screenshots/round19-dashboard.png b/qa-screenshots/round19-dashboard.png new file mode 100644 index 0000000..9950bd1 Binary files /dev/null and b/qa-screenshots/round19-dashboard.png differ diff --git a/qa-screenshots/round19-districts.png b/qa-screenshots/round19-districts.png new file mode 100644 index 0000000..4214520 Binary files /dev/null and b/qa-screenshots/round19-districts.png differ diff --git a/qa-screenshots/round19-settings.png b/qa-screenshots/round19-settings.png new file mode 100644 index 0000000..386dda4 Binary files /dev/null and b/qa-screenshots/round19-settings.png differ diff --git a/qa-screenshots/round19-student-portal-enhanced.png b/qa-screenshots/round19-student-portal-enhanced.png new file mode 100644 index 0000000..e3cc5f2 Binary files /dev/null and b/qa-screenshots/round19-student-portal-enhanced.png differ diff --git a/qa-screenshots/round19-student-portal.png b/qa-screenshots/round19-student-portal.png new file mode 100644 index 0000000..6c36d43 Binary files /dev/null and b/qa-screenshots/round19-student-portal.png differ diff --git a/src/app/api/districts/[id]/schools/route.ts b/src/app/api/districts/[id]/schools/route.ts index 66752a9..00f58b7 100644 --- a/src/app/api/districts/[id]/schools/route.ts +++ b/src/app/api/districts/[id]/schools/route.ts @@ -58,3 +58,38 @@ export async function POST( return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 }); } } + +// DELETE /api/districts/[id]/schools — remove school from district +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { searchParams } = new URL(_req.url); + const schoolId = searchParams.get('schoolId'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // Verify the school belongs to this district + const school = await db.school.findFirst({ + where: { id: schoolId, districtId: id }, + }); + + if (!school) { + return NextResponse.json({ error: 'School not found in this district' }, { status: 404 }); + } + + await db.school.update({ + where: { id: schoolId }, + data: { districtId: null }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[districts/[id]/schools] DELETE error:', error); + return NextResponse.json({ error: 'Failed to remove school from district' }, { status: 500 }); + } +} diff --git a/src/app/api/districts/route.ts b/src/app/api/districts/route.ts index e6ad9a3..3fce1d9 100644 --- a/src/app/api/districts/route.ts +++ b/src/app/api/districts/route.ts @@ -8,7 +8,15 @@ export async function GET() { where: { deletedAt: null }, include: { schools: { - select: { id: true, name: true, schoolType: true, country: true }, + select: { + id: true, + name: true, + schoolType: true, + country: true, + _count: { + select: { students: true, classGroups: true, users: true }, + }, + }, }, }, orderBy: { name: 'asc' }, @@ -18,7 +26,9 @@ export async function GET() { const result = districts.map((d) => ({ ...d, schoolCount: d.schools.length, - totalStudents: 0, // Would need aggregation query in production + totalStudents: d.schools.reduce((acc, s) => acc + s._count.students, 0), + totalTeachers: d.schools.reduce((acc, s) => acc + s._count.users, 0), + totalClasses: d.schools.reduce((acc, s) => acc + s._count.classGroups, 0), })); return NextResponse.json(result); diff --git a/src/app/api/student-goals/[id]/route.ts b/src/app/api/student-goals/[id]/route.ts new file mode 100644 index 0000000..7b2f3b8 --- /dev/null +++ b/src/app/api/student-goals/[id]/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +/** + * Check if a student belongs to the current user (STUDENT role). + * Uses userId (direct link) with name-matching fallback for legacy data. + */ +function isOwnStudent( + student: { userId: string | null; firstName: string; lastName: string; schoolId: string | null }, + user: { id: string; firstName: string; lastName: string; schoolId: string | null } +): boolean { + if (student.userId === user.id) return true; + // Fallback: match by name + school for legacy records without userId + if ( + student.userId === null && + student.firstName === user.firstName && + student.lastName === user.lastName && + student.schoolId === user.schoolId + ) { + return true; + } + return false; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const goal = await db.studentGoal.findUnique({ + where: { id }, + include: { + student: { + select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true }, + }, + }, + }); + + if (!goal) { + return NextResponse.json({ error: 'Goal not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'STUDENT') { + if (!isOwnStudent(goal.student, session.user)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (session.user?.role !== 'SUPER_ADMIN') { + if (goal.student.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + return NextResponse.json(goal); + } catch (error) { + console.error('StudentGoal GET by ID error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const { title, description, targetDate, progress, status } = body; + + const existingGoal = await db.studentGoal.findUnique({ + where: { id }, + include: { + student: { + select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true }, + }, + }, + }); + + if (!existingGoal) { + return NextResponse.json({ error: 'Goal not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'STUDENT') { + if (!isOwnStudent(existingGoal.student, session.user)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (session.user?.role !== 'SUPER_ADMIN') { + if (existingGoal.student.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const updateData: Record = {}; + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (targetDate !== undefined) updateData.targetDate = targetDate ? new Date(targetDate) : null; + if (progress !== undefined) updateData.progress = Math.min(100, Math.max(0, progress)); + if (status !== undefined) updateData.status = status; + + // Auto-complete if progress reaches 100 + if (progress !== undefined && progress >= 100) { + updateData.status = 'completed'; + updateData.progress = 100; + } + + const goal = await db.studentGoal.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(goal); + } catch (error) { + console.error('StudentGoal PUT error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existingGoal = await db.studentGoal.findUnique({ + where: { id }, + include: { + student: { + select: { id: true, firstName: true, lastName: true, schoolId: true, userId: true }, + }, + }, + }); + + if (!existingGoal) { + return NextResponse.json({ error: 'Goal not found' }, { status: 404 }); + } + + // Verify access + if (session.user?.role === 'STUDENT') { + if (!isOwnStudent(existingGoal.student, session.user)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (session.user?.role !== 'SUPER_ADMIN') { + if (existingGoal.student.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + await db.studentGoal.delete({ + where: { id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('StudentGoal DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/student-goals/route.ts b/src/app/api/student-goals/route.ts new file mode 100644 index 0000000..202140e --- /dev/null +++ b/src/app/api/student-goals/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const studentId = searchParams.get('studentId'); + const status = searchParams.get('status'); + + if (!studentId) { + return NextResponse.json({ error: 'studentId is required' }, { status: 400 }); + } + + // Verify the student belongs to the same school or is the student themselves + if (session.user?.role === 'STUDENT') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { userId: true, schoolId: true }, + }); + if (!student || student.userId !== session.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (session.user?.role !== 'SUPER_ADMIN') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { schoolId: true }, + }); + if (!student || student.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const where: Record = { studentId }; + if (status) where.status = status; + + const goals = await db.studentGoal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(goals); + } catch (error) { + console.error('StudentGoal GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { studentId, title, description, targetDate, progress, status } = body; + + if (!studentId || !title) { + return NextResponse.json( + { error: 'studentId and title are required' }, + { status: 400 } + ); + } + + // Verify the student belongs to the same school or is the student themselves + if (session.user?.role === 'STUDENT') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { userId: true, schoolId: true, firstName: true, lastName: true }, + }); + if (!student) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + // Check by userId (direct link) or by name matching (fallback for legacy data) + const isOwnStudent = student.userId === session.user.id || + (student.userId === null && + student.firstName === session.user.firstName && + student.lastName === session.user.lastName && + student.schoolId === session.user.schoolId); + if (!isOwnStudent) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } else if (session.user?.role !== 'SUPER_ADMIN') { + const student = await db.student.findUnique({ + where: { id: studentId }, + select: { schoolId: true }, + }); + if (!student || student.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + const goal = await db.studentGoal.create({ + data: { + studentId, + title, + description: description || null, + targetDate: targetDate ? new Date(targetDate) : null, + progress: progress || 0, + status: status || 'active', + }, + }); + + return NextResponse.json(goal, { status: 201 }); + } catch (error) { + console.error('StudentGoal POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/students/route.ts b/src/app/api/students/route.ts index 14adf8c..30c860f 100644 --- a/src/app/api/students/route.ts +++ b/src/app/api/students/route.ts @@ -25,11 +25,31 @@ export async function GET(request: Request) { const schoolId = searchParams.get('schoolId'); const classGroupId = searchParams.get('classGroupId'); const search = searchParams.get('search'); + const userId = searchParams.get('userId'); const where: Record = { deletedAt: null }; if (schoolId) where.schoolId = schoolId; + // Filter by userId (direct link to User account) + let userIdFilter: Record[] | null = null; + if (userId) { + // For STUDENT role, also match by name + school as fallback for legacy data + if (session.user?.role === 'STUDENT' && session.user.id === userId) { + userIdFilter = [ + { userId }, + { + userId: null, + firstName: session.user.firstName, + lastName: session.user.lastName, + schoolId: session.user.schoolId, + }, + ]; + } else { + where.userId = userId; + } + } + if (classGroupId) { where.enrollments = { some: { classGroupId, endDate: null }, @@ -37,11 +57,22 @@ export async function GET(request: Request) { } if (search) { - where.OR = [ + const searchOr = [ { firstName: { contains: search } }, { lastName: { contains: search } }, { externalId: { contains: search } }, ]; + // If userId filter also uses OR, combine with AND + if (userIdFilter) { + where.AND = [ + { OR: userIdFilter }, + { OR: searchOr }, + ]; + } else { + where.OR = searchOr; + } + } else if (userIdFilter) { + where.OR = userIdFilter; } const students = await db.student.findMany({ diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 2fe0d08..7bb6f92 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -59,6 +59,7 @@ import { Library as LibraryIcon, Trophy, FolderOpen, + Building2, } from 'lucide-react'; import { Sidebar, @@ -131,6 +132,7 @@ import { Skeleton } from '@/components/ui/skeleton'; // Static imports for lightweight views import DashboardView from './dashboard-view'; +import StudentPortalView from './student-portal-view'; import ClassesView from './classes-view'; import CompetencyGridView from './competency-grid-view'; import ProgressEntriesView from './progress-entries-view'; @@ -153,6 +155,7 @@ import PortfolioView from './portfolio-view'; import TimetableView from './timetable-view'; import ResourceLibraryView from './resource-library-view'; import CompetitionsView from './competitions-view'; +import DistrictManagementView from './district-management-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -218,6 +221,7 @@ const navSections: NavSection[] = [ labelKey: 'polish.nav_setup', items: [ { key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' }, + { key: 'districts', icon: Building2, labelKey: 'nav.districts' }, { key: 'settings', icon: Settings, labelKey: 'nav.settings' }, ], }, @@ -230,6 +234,7 @@ const studentNavSections: NavSection[] = [ labelKey: 'student.dashboard_title', items: [ { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, + { key: 'student-portal', icon: GraduationCap, labelKey: 'student_portal.title' }, { key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' }, { key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' }, { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, @@ -361,6 +366,7 @@ function AnnouncementBanner({ schoolId }: { schoolId: string | null }) { function renderView(view: ViewName) { switch (view) { case 'dashboard': return ; + case 'student-portal': return ; case 'classes': return ; case 'competencies': return ; case 'progress': return ; @@ -387,6 +393,7 @@ function renderView(view: ViewName) { case 'timetable': return ; case 'resources': return ; case 'competitions': return ; + case 'districts': return ; default: return ; } } diff --git a/src/components/auth-view.tsx b/src/components/auth-view.tsx index c64b063..725c9a8 100644 --- a/src/components/auth-view.tsx +++ b/src/components/auth-view.tsx @@ -1,8 +1,8 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle } from 'lucide-react'; +import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf, Users as UsersIcon, HelpCircle, BarChart3, LockKeyhole, GitBranch } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -23,6 +23,301 @@ const DEMO_ACCOUNTS = [ { email: 'demo.parent@competencetrack.org', password: 'Demo2025!', role: 'PARENT', labelKey: 'auth.demo_parent', icon: Heart, colorClass: 'from-violet-400 to-violet-600 hover:from-violet-500 hover:to-violet-700 shadow-violet-300/40 dark:shadow-violet-900/40' }, ]; +/* ─── Staggered entrance helpers ────────────────────────────── */ +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.07, delayChildren: 0.1 }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 14 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: 'easeOut' } }, +}; + +/* ─── Floating particles ────────────────────────────────────── */ +function FloatingParticles() { + const particles = useMemo(() => [ + { id: 'p1', x: 8, y: 12, size: 6, opacity: 0.25, color: 'emerald', delay: 0, duration: 14 }, + { id: 'p2', x: 85, y: 18, size: 4, opacity: 0.2, color: 'teal', delay: 2, duration: 18 }, + { id: 'p3', x: 22, y: 65, size: 5, opacity: 0.18, color: 'amber', delay: 4, duration: 16 }, + { id: 'p4', x: 70, y: 75, size: 3, opacity: 0.22, color: 'violet', delay: 1, duration: 20 }, + { id: 'p5', x: 45, y: 88, size: 4, opacity: 0.15, color: 'emerald', delay: 3, duration: 12 }, + { id: 'p6', x: 92, y: 45, size: 5, opacity: 0.2, color: 'teal', delay: 5, duration: 15 }, + { id: 'p7', x: 15, y: 40, size: 3, opacity: 0.18, color: 'amber', delay: 6, duration: 22 }, + { id: 'p8', x: 55, y: 10, size: 4, opacity: 0.15, color: 'violet', delay: 2.5, duration: 17 }, + { id: 'p9', x: 35, y: 50, size: 2, opacity: 0.12, color: 'emerald', delay: 7, duration: 19 }, + { id: 'p10', x: 78, y: 55, size: 3, opacity: 0.16, color: 'teal', delay: 1.5, duration: 13 }, + { id: 'p11', x: 60, y: 30, size: 2, opacity: 0.14, color: 'amber', delay: 4.5, duration: 21 }, + { id: 'p12', x: 5, y: 80, size: 4, opacity: 0.2, color: 'violet', delay: 3.5, duration: 16 }, + ], []); + + const colorMap: Record = { + emerald: 'bg-emerald-400 dark:bg-emerald-500', + teal: 'bg-teal-400 dark:bg-teal-500', + amber: 'bg-amber-400 dark:bg-amber-500', + violet: 'bg-violet-400 dark:bg-violet-500', + }; + + return ( +
+ {particles.map((p) => ( + + ))} +
+ ); +} + +/* ─── Decorative geometric shapes ───────────────────────────── */ +function FloatingShapes() { + return ( +
+ {/* Large circles */} +
+
+
+ + {/* Rotated squares / diamonds */} + + + + + {/* Hexagonal shapes (approximated with rounded corners) */} + + + + {/* Dot grid overlay */} +
+
+ ); +} + +/* ─── Left-side illustration panel ──────────────────────────── */ +function LeftIllustration({ loginRole }: { loginRole: LoginRole }) { + const features = [ + { icon: Sparkles, labelKey: 'auth.feature_competencies', color: 'emerald' }, + { icon: Heart, labelKey: 'auth.feature_student_centered', color: 'teal' }, + { icon: BookOpen, labelKey: 'auth.feature_open_source', color: 'amber' }, + { icon: GraduationCap, labelKey: 'auth.feature_reports', color: 'violet' }, + { icon: Flower2, labelKey: 'auth.feature_flower', color: 'rose' }, + ]; + + const colorClasses: Record = { + emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400', ring: 'ring-emerald-200/50 dark:ring-emerald-800/30' }, + teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400', ring: 'ring-teal-200/50 dark:ring-teal-800/30' }, + amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400', ring: 'ring-amber-200/50 dark:ring-amber-800/30' }, + violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400', ring: 'ring-violet-200/50 dark:ring-violet-800/30' }, + rose: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400', ring: 'ring-rose-200/50 dark:ring-rose-800/30' }, + }; + + return ( + +
+ {/* Decorative orbiting circles */} + + + + + + {/* Main icon with role-based animation */} + + + {loginRole === 'teacher' && ( + + + + )} + {loginRole === 'student' && ( + + + + )} + {loginRole === 'parent' && ( + + + + )} + + +
+ + {/* Feature highlights */} + +

+ {t('auth.mission_title')} +

+
+ {features.map((f, i) => { + const c = colorClasses[f.color]; + return ( + +
+ +
+

{t(f.labelKey)}

+
+ ); + })} +
+ + {/* Mockup dashboard preview */} + +
+
+
+
+
+
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+
+
+
+
+ + + + ); +} + +/* ─── Features section below form ───────────────────────────── */ +function FeaturesSection() { + const features = [ + { icon: BarChart3, labelKey: 'auth.feature_competencies', color: 'emerald' }, + { icon: LockKeyhole, labelKey: 'auth.feature_data_security', color: 'teal' }, + { icon: GitBranch, labelKey: 'auth.feature_open_source', color: 'amber' }, + { icon: Flower2, labelKey: 'auth.feature_flower', color: 'violet' }, + ]; + + const colorClasses: Record = { + emerald: { bg: 'bg-emerald-50 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400' }, + teal: { bg: 'bg-teal-50 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400' }, + amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + violet: { bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, + }; + + return ( + +
+ {features.map((f, i) => { + const c = colorClasses[f.color]; + return ( + + + {t(f.labelKey)} + + ); + })} +
+
+ ); +} + +/* ─── Main Auth View ────────────────────────────────────────── */ export default function AuthView() { const [mode, setMode] = useState<'login' | 'register'>('login'); const [loginRole, setLoginRole] = useState('teacher'); @@ -114,385 +409,462 @@ export default function AuthView() { } }; - // Role tab config - const roleTabs: Array<{ key: LoginRole; icon: React.ElementType; labelKey: string; color: string; activeColor: string; bgGradient: string }> = [ - { key: 'teacher', icon: GraduationCap, labelKey: 'auth.login', color: 'text-emerald-600 dark:text-emerald-400', activeColor: 'bg-emerald-500 text-white', bgGradient: 'from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10' }, - { key: 'student', icon: UsersIcon, labelKey: 'auth.student_login', color: 'text-amber-600 dark:text-amber-400', activeColor: 'bg-amber-500 text-white', bgGradient: 'from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10' }, - { key: 'parent', icon: Heart, labelKey: 'auth.parent_login', color: 'text-violet-600 dark:text-violet-400', activeColor: 'bg-violet-500 text-white', bgGradient: 'from-violet-50/50 to-purple-50/50 dark:from-violet-950/10 dark:to-purple-950/10' }, + // Role tab config with enhanced visual distinction + const roleTabs: Array<{ + key: LoginRole; + icon: React.ElementType; + labelKey: string; + color: string; + activeColor: string; + activeBg: string; + bgGradient: string; + hoverBg: string; + indicatorColor: string; + glowColor: string; + }> = [ + { + key: 'teacher', + icon: GraduationCap, + labelKey: 'auth.login', + color: 'text-emerald-600 dark:text-emerald-400', + activeColor: 'text-white', + activeBg: 'bg-gradient-to-r from-emerald-500 to-teal-500', + bgGradient: 'from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10', + hoverBg: 'hover:bg-emerald-50/60 dark:hover:bg-emerald-950/20', + indicatorColor: 'bg-emerald-500', + glowColor: 'shadow-emerald-200/50 dark:shadow-emerald-900/30', + }, + { + key: 'student', + icon: UsersIcon, + labelKey: 'auth.student_login', + color: 'text-amber-600 dark:text-amber-400', + activeColor: 'text-white', + activeBg: 'bg-gradient-to-r from-amber-500 to-orange-500', + bgGradient: 'from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10', + hoverBg: 'hover:bg-amber-50/60 dark:hover:bg-amber-950/20', + indicatorColor: 'bg-amber-500', + glowColor: 'shadow-amber-200/50 dark:shadow-amber-900/30', + }, + { + key: 'parent', + icon: Heart, + labelKey: 'auth.parent_login', + color: 'text-violet-600 dark:text-violet-400', + activeColor: 'text-white', + activeBg: 'bg-gradient-to-r from-violet-500 to-purple-500', + bgGradient: 'from-violet-50/50 to-purple-50/50 dark:from-violet-950/10 dark:to-purple-950/10', + hoverBg: 'hover:bg-violet-50/60 dark:hover:bg-violet-950/20', + indicatorColor: 'bg-violet-500', + glowColor: 'shadow-violet-200/50 dark:shadow-violet-900/30', + }, ]; const activeRoleTab = roleTabs.find((r) => r.key === loginRole)!; return ( -
- {/* Animated floating geometric shapes background */} -
-
-
-
-
-
-
-
-
+
+ {/* Animated gradient background overlay */} +
+ + {/* Floating geometric shapes */} + + + {/* Floating particles */} + {/* Left illustration side (hidden on small screens) */} - -
- - - - {/* Main icon changes based on login role */} - - - {loginRole === 'teacher' && } - {loginRole === 'student' && } - {loginRole === 'parent' && } - - -
- - {/* Feature highlights */} - -

{t('auth.mission_title')}

-
-
-
-

{t('auth.feature_competencies')}

-
-
-
-

{t('auth.feature_student_centered')}

-
-
-
-

{t('auth.feature_open_source')}

-
-
-
-

{t('auth.feature_reports')}

-
-
-
-

{t('auth.feature_flower')}

-
-
- - {/* Mockup dashboard preview */} - -
-
-
-
-
-
-
- {[0, 1, 2, 3, 4, 5].map((i) => ( -
- ))} -
-
-
-
-
-
- - - + {/* Auth form */} - - {/* Branding */} + + {/* Branding (mobile only) */}
- + -

{t('app.name')}

-

{t('app.subtitle')}

+ + {t('app.name')} + + + {t('app.subtitle')} +
- {/* Auth Card */} - - {/* Role tabs */} -
- {roleTabs.map((tab) => ( - - ))} -
- - - - - - {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login') : t('auth.register_title')) - : loginRole === 'student' ? t('auth.student_login') - : t('auth.parent_login')} - -

- {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login_subtitle') : t('auth.register_subtitle')) - : loginRole === 'student' ? t('auth.student_login_desc') - : t('auth.parent_login_desc')} -

-
-
-
- - -
- - {/* Name fields for register (teacher only) */} - {mode === 'register' && loginRole === 'teacher' && ( - -
-
- -
- - setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" placeholder={t('auth.firstName')} required /> -
-
-
- - setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base" placeholder={t('auth.lastName')} required /> -
-
-
- )} - - {/* Student-specific: School ID field */} - {loginRole === 'student' && ( - - -
- - setSchoolIdInput(e.target.value)} className="pl-10 h-12 min-h-[44px] border-amber-200/50 dark:border-amber-900/30 focus:border-amber-400 focus:ring-amber-400/20 text-base" placeholder={t('auth.school_id')} /> -
-
- )} - - {/* Parent-specific: name fields on login */} - {loginRole === 'parent' && mode === 'login' && ( - -
- -

{t('auth.parent_login_desc')}

-
-
- )} -
- - {/* Email field (always shown) */} -
- -
- - setEmail(e.target.value)} - className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base ${ - loginRole === 'student' ? 'focus:border-amber-400 focus:ring-amber-400/20' - : loginRole === 'parent' ? 'focus:border-violet-400 focus:ring-violet-400/20' - : '' - }`} - placeholder={loginRole === 'student' ? 'name@schule.de' : loginRole === 'parent' ? 'parent@email.de' : 'name@schule.de'} - required - /> -
-
- - {/* Password field */} -
-
- - {mode === 'login' && ( - + {/* Auth Card - Glassmorphism */} + + + {/* Role tabs with enhanced visual distinction */} +
+ {roleTabs.map((tab, i) => ( + { setLoginRole(tab.key); setShowForgotInfo(false); }} + className={`relative flex-1 min-h-[48px] flex items-center justify-center gap-2 px-3 py-3.5 text-sm font-semibold transition-all duration-300 ${ + loginRole === tab.key + ? `${tab.activeBg} ${tab.activeColor} shadow-sm ${tab.glowColor}` + : `${tab.color} bg-transparent ${tab.hoverBg}` + }`} + initial={{ opacity: 0, y: -8 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: 0.2 + i * 0.05 }} + whileHover={{ scale: loginRole === tab.key ? 1 : 1.02 }} + whileTap={{ scale: 0.98 }} + > + + {t(tab.labelKey)} + {/* Active indicator */} + {loginRole === tab.key && ( + )} -
-
- - setPassword(e.target.value)} - className="pl-9 pr-10 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20" - placeholder="••••••" - minLength={6} - required - /> - -
+ + ))} +
- {/* Forgot Password info */} - - {showForgotInfo && ( - -
- -

- {loginRole === 'student' - ? (t('polish.demo_email') + ': demo.student@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') - : loginRole === 'parent' - ? (t('polish.demo_email') + ': demo.parent@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') - : (t('polish.demo_email') + ': demo@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') - } -

+ + + + + {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login') : t('auth.register_title')) + : loginRole === 'student' ? t('auth.student_login') + : t('auth.parent_login')} + +

+ {loginRole === 'teacher' ? (mode === 'login' ? t('auth.login_subtitle') : t('auth.register_subtitle')) + : loginRole === 'student' ? t('auth.student_login_desc') + : t('auth.parent_login_desc')} +

+
+
+
+ + + + + {/* Name fields for register (teacher only) */} + {mode === 'register' && loginRole === 'teacher' && ( + + +
+ +
+ + setFirstName(e.target.value)} className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.firstName')} required /> +
+
+
+ + setLastName(e.target.value)} className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.lastName')} required /> +
+
+
+ )} + + {/* Student-specific: School ID field */} + {loginRole === 'student' && ( + + +
+ + setSchoolIdInput(e.target.value)} className="pl-10 h-12 min-h-[44px] border-amber-200/50 dark:border-amber-900/30 focus:border-amber-400 focus:ring-amber-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm" placeholder={t('auth.school_id')} /> +
+
+ )} + + {/* Parent-specific: name fields on login */} + {loginRole === 'parent' && mode === 'login' && ( + +
+ +

{t('auth.parent_login_desc')}

)}
-
- {/* Remember me (login mode only) */} + {/* Email field (always shown) */} + + +
+ + setEmail(e.target.value)} + className={`pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200 ${ + loginRole === 'student' ? 'focus:border-amber-400 focus:ring-amber-400/20' + : loginRole === 'parent' ? 'focus:border-violet-400 focus:ring-violet-400/20' + : '' + }`} + placeholder={loginRole === 'student' ? 'name@schule.de' : loginRole === 'parent' ? 'parent@email.de' : 'name@schule.de'} + required + /> +
+
+ + {/* Password field */} + +
+ + {mode === 'login' && ( + setShowForgotInfo(!showForgotInfo)} + className="text-xs text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 font-medium transition-colors" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {t('auth.forgot_password')} + + )} +
+
+ + setPassword(e.target.value)} + className="pl-9 pr-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm transition-all duration-200" + placeholder="••••••" + minLength={6} + required + /> + setShowPassword(!showPassword)} + className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-10 h-10 flex items-center justify-center" + tabIndex={-1} + aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + {showPassword ? : } + +
+ + {/* Forgot Password info */} + + {showForgotInfo && ( + +
+ +

+ {loginRole === 'student' + ? (t('polish.demo_email') + ': demo.student@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') + : loginRole === 'parent' + ? (t('polish.demo_email') + ': demo.parent@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') + : (t('polish.demo_email') + ': demo@competencetrack.org / ' + t('polish.demo_password') + ': Demo2025!') + } +

+
+
+ )} +
+
+ + {/* Remember me (login mode only) */} + {mode === 'login' && ( + + setRememberMe(v === true)} className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" /> + + + )} + + {/* Submit button with glow effect */} + + + + + + {/* Role-specific demo login buttons */} {mode === 'login' && ( -
- setRememberMe(v === true)} className="border-emerald-300 dark:border-emerald-700 data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500" /> - -
+ +
+
+ +

{t('auth.demo_section_title')}

+ {t('badge.demo')} +
+

{t('auth.demo_section_subtitle')}

+
+
+ {DEMO_ACCOUNTS.map((demo, i) => ( + + + + ))} +
+
)} - {/* Submit button */} - - - - {/* Role-specific demo login buttons */} - {mode === 'login' && ( - -
-
- -

{t('auth.demo_section_title')}

- {t('badge.demo')} -
-

{t('auth.demo_section_subtitle')}

-
-
- {DEMO_ACCOUNTS.map((demo) => ( - - ))} -
-
- )} - - {/* Toggle register/login (only for teacher role) */} - {loginRole === 'teacher' && ( -
- -
- )} + + {mode === 'login' ? t('auth.no_account') : t('auth.has_account')} + +
+ )} - {/* Student info: accounts created by teachers */} - {loginRole === 'student' && mode === 'login' && ( -
-
- -

- {t('auth.create_student_account')} — {t('auth.student_login_desc')} -

-
- {/* Jugendschutz notice for students */} -
- -

- {t('dsgvo.jugendschutz_login_notice')} -

-
-
- )} + {/* Student info: accounts created by teachers */} + {loginRole === 'student' && mode === 'login' && ( + +
+ +

+ {t('auth.create_student_account')} — {t('auth.student_login_desc')} +

+
+ {/* Jugendschutz notice for students */} +
+ +

+ {t('dsgvo.jugendschutz_login_notice')} +

+
+
+ )} - {/* Parent info */} - {loginRole === 'parent' && mode === 'login' && ( -
-
- -

- {t('parent.my_children')} — {t('auth.parent_login_desc')} -

-
-
- )} - - + {/* Parent info */} + {loginRole === 'parent' && mode === 'login' && ( + +
+ +

+ {t('parent.my_children')} — {t('auth.parent_login_desc')} +

+
+
+ )} + + + + + {/* Features section below form */} + {/* Footer */} -
+

{t('app.tagline')}

-
+
); diff --git a/src/components/dashboard-view.tsx b/src/components/dashboard-view.tsx index 52fd1d0..e18e684 100644 --- a/src/components/dashboard-view.tsx +++ b/src/components/dashboard-view.tsx @@ -234,6 +234,30 @@ const motivationalTips = [ { text: 'Detailed records make parent conversations productive.', icon: MessageSquare }, ]; +// ─── Time-based Greeting Helper ──────────────────────────────────── +function getTimeGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return t('dashboard.greeting_morning'); + if (hour < 18) return t('dashboard.greeting_afternoon'); + return t('dashboard.greeting_evening'); +} + +// ─── Gradient Border Card Wrapper ────────────────────────────────── +function GradientBorderCard({ children, className = '', gradientFrom = 'from-emerald-400', gradientTo = 'to-teal-500' }: { + children: React.ReactNode; + className?: string; + gradientFrom?: string; + gradientTo?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} + // ─── Parent Dashboard Component ──────────────────────────────────────── function ParentDashboard({ currentUser, setCurrentView, locale }: { currentUser: CurrentUser; @@ -848,7 +872,7 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: { - +
@@ -866,6 +890,9 @@ function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: { outerRadius={80} paddingAngle={3} dataKey="value" + isAnimationActive + animationDuration={1200} + animationEasing="ease-out" > {gradeDistribution.map((entry, index) => ( @@ -1579,7 +1606,7 @@ export default function DashboardView() { - + @@ -1658,12 +1685,17 @@ export default function DashboardView() { {/* Top row: welcome + date */}
-
+ -
+

- {t('dashboard.welcome')}, {currentUser?.firstName}! + {getTimeGreeting()}, {currentUser?.firstName}!

{t('dashboard.overview')}

@@ -1698,20 +1730,27 @@ export default function DashboardView() {
- {/* Tip of the day */} + {/* Tip of the day — enhanced with gradient border */} -
- +
+
+ + + +

+ {t('dashboard.daily_tip')}:{' '} + {dailyTip.text} +

-

- {locale === 'de' ? 'Tipp des Tages' : 'Tip of the day'}:{' '} - {dailyTip.text} -

@@ -1815,23 +1854,36 @@ export default function DashboardView() { - + + {/* Gradient top border */} +

{stat.label}

-
+ -
+
-

+

+ +

{stat.value > 0 && stat.trend === 'up' && ( -
+ {trendPct > 0 ? `+${trendPct}%` : ''} -
+ )} {stat.value === 0 && ( {t('dashboard.start_here')} @@ -1868,7 +1920,7 @@ export default function DashboardView() {
- {locale === 'de' ? 'Aktualisiert' : 'Updated'} {currentTime} + {t('dashboard.updated_at')} {currentTime}
@@ -2040,8 +2092,8 @@ export default function DashboardView() {
- {t('teacher.competence_progress')} - {t('teacher.competence_progress_desc')} + {t('dashboard.competency_progress')} + {t('dashboard.competency_progress_desc')} @@ -2057,12 +2109,41 @@ export default function DashboardView() { - - + + + + {/* Animated progress bars for each subject */} +
+

{t('dashboard.competency_progress')}

+ {[ + { subject: t('student.notebook_math'), current: 3.5, target: 4, color: 'from-amber-400 to-orange-500' }, + { subject: t('student.notebook_german'), current: 3.8, target: 4, color: 'from-emerald-400 to-teal-500' }, + { subject: t('student.notebook_english'), current: 2.9, target: 4, color: 'from-rose-400 to-pink-500' }, + { subject: t('student.notebook_science'), current: 3.2, target: 4, color: 'from-violet-400 to-purple-500' }, + ].map((item) => { + const pct = Math.round((item.current / item.target) * 100); + return ( +
+
+ {item.subject} + {item.current}/{item.target} +
+
+ +
+
+ ); + })} +
@@ -2128,7 +2209,7 @@ export default function DashboardView() {
- {locale === 'de' ? 'Heutige Aufgaben' : 'Today\'s Schedule'} + {t('dashboard.class_overview_grid')} · {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} @@ -2137,8 +2218,8 @@ export default function DashboardView() {
{[ - { icon: PenLine, label: locale === 'de' ? 'Fortschritte erfassen' : 'Log progress entries', desc: locale === 'de' ? 'Neue Beobachtungen eintragen' : 'Record new observations', view: 'progress', color: 'from-emerald-400 to-teal-500' }, - { icon: ClipboardCheck, label: locale === 'de' ? 'Uberpruefung erstellen' : 'Create assessment', desc: locale === 'de' ? 'Leistungsueberpruefung planen' : 'Plan performance assessment', view: 'assessments', color: 'from-amber-400 to-rose-500' }, + { icon: PenLine, label: t('action.log_entry'), desc: t('dashboard.quick_action_assessment_desc'), view: 'progress', color: 'from-emerald-400 to-teal-500' }, + { icon: ClipboardCheck, label: t('action.create_assessment'), desc: t('dashboard.quick_action_assessment_desc'), view: 'assessments', color: 'from-amber-400 to-rose-500' }, { icon: BookOpen, label: locale === 'de' ? 'Kompetenzblume ansehen' : 'View competence flower', desc: locale === 'de' ? 'Radar-Chart pruefen' : 'Check radar chart', view: 'flower', color: 'from-violet-400 to-emerald-500' }, { icon: Notebook, label: locale === 'de' ? 'Notizbuch aktualisieren' : 'Update notebook', desc: locale === 'de' ? 'Hefte bearbeiten' : 'Edit notebooks', view: 'notebooks', color: 'from-teal-400 to-emerald-500' }, ].map((item, i) => ( @@ -2329,6 +2410,99 @@ export default function DashboardView() {
+ {/* ===== CLASS OVERVIEW MINI CARD GRID ===== */} + + + +
+ +
+ +
+ {t('dashboard.class_overview_grid')} + {data.classesOverview.length > 0 && ( + + {data.classesOverview.length} + + )} +
+ +
+
+ + {data.classesOverview.length === 0 ? ( +
+
+ +
+

{t('dashboard.no_classes')}

+

{t('dashboard.start_here')}

+
+ ) : ( +
+ {data.classesOverview.map((cls, idx) => { + const gradientIdx = idx % avatarGradients.length; + const totalStudents = cls.studentCount; + const maxStudents = Math.max(...data.classesOverview.map(c => c.studentCount), 1); + const fillPct = Math.round((totalStudents / maxStudents) * 100); + + return ( + { + useAppStore.getState().setCurrentClass(cls.id); + setCurrentView('classes'); + }} + > +
+ {/* Gradient top strip */} +
+
+
+
+ {cls.name.substring(0, 2).toUpperCase()} +
+
+

{cls.name}

+

{t('label.grade')} {cls.gradeLevel}

+
+
+ {/* Mini student count bar */} +
+ {t('dashboard.total_students')} + {cls.studentCount} +
+
+ +
+
+
+ + ); + })} +
+ )} + + +
+ {/* ===== MAIN CONTENT GRID ===== */}
{/* Classes overview */} @@ -2481,16 +2655,19 @@ export default function DashboardView() {
- {/* ===== RECENT ACTIVITY ===== */} + {/* ===== RECENT ACTIVITY TIMELINE ===== */} - -
- -
- {t('dashboard.recent_activity')} -
+
+ +
+ +
+ {t('dashboard.activity_timeline')} +
+ {t('dashboard.activity_timeline_desc')} +
{data.recentEntries.length === 0 ? ( @@ -2502,18 +2679,27 @@ export default function DashboardView() {

{t('dashboard.start_here')}

) : ( -
- {data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry) => { +
+ {/* Timeline connector line */} +
+ {data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry, idx) => { const catColor = entry.competency.category.color ?? '#10b981'; const studentName = `${entry.student.firstName} ${entry.student.lastName}`; const gradient = getAvatarGradient(studentName); return ( + {/* Timeline dot */} +
+
+
+ {/* Left border color indicator */}
@@ -2670,9 +2856,11 @@ export default function DashboardView() { {/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */}
- {/* Paper Saved Counter */} + {/* Paper Saved Counter — enhanced with CountUp */} - + + {/* Gradient top border */} +

- {locale === 'de' ? 'Papier gespart' : 'Paper Saved'} + {t('dashboard.paper_saved')}

- - {data.stats.totalProgressEntries * 50} + + - {locale === 'de' ? 'Seiten' : 'pages'} + {t('dashboard.paper_saved_pages')}

- {locale === 'de' - ? 'Jeder digitale Eintrag spart ca. 50 Seiten Papier. Danke fuer Ihren Beitrag zum Umweltschutz!' - : 'Each digital entry saves ~50 pages of paper. Thank you for contributing to environmental protection!'} + {t('dashboard.env_tip_text')}

-
+ - {locale === 'de' ? '~0.5 Baume' : '~0.5 trees'} -
-
+ ~{(data.stats.totalProgressEntries * 50 / 8000).toFixed(1)} {t('dashboard.paper_saved_trees')} + + - {locale === 'de' ? 'CO2 reduziert' : 'CO2 reduced'} -
+ {(data.stats.totalProgressEntries * 50 * 0.005).toFixed(1)} {t('dashboard.paper_saved_co2')} +
@@ -2751,6 +2947,53 @@ export default function DashboardView() {
+ {/* ===== SCHOOL BRANDING SECTION ===== */} + + + {/* Gradient top border */} +
+ +
+ + + +
+

+ {t('dashboard.school_branding')} +

+

+ {t('dashboard.school_branding_desc')} +

+
+
+
+
+
+
+
+
+ {t('app.name')} +
+
+
+ + + {currentUser?.schoolId ? t('nav.classes') : 'CompetenceTrack'} + + + {t('dashboard.total_students')}: {data.stats.totalStudents} · {t('dashboard.total_classes')}: {data.stats.totalClasses} + +
+
+ + + + {/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */} diff --git a/src/components/district-management-view.tsx b/src/components/district-management-view.tsx new file mode 100644 index 0000000..6e95c7b --- /dev/null +++ b/src/components/district-management-view.tsx @@ -0,0 +1,1317 @@ +'use client'; + +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Building2, + School, + Users, + BarChart3, + MapPin, + Plus, + Edit3, + Trash2, + Search, + ArrowLeft, + Mail, + CheckCircle2, + XCircle, + TrendingUp, + GraduationCap, + UserCheck, + Activity, + Loader2, + ChevronRight, + X, + AlertTriangle, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RTooltip, + ResponsiveContainer, + Legend, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, +} from 'recharts'; +import { useAppStore, type CurrentUser } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; +import { toast } from 'sonner'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +// ── Types ────────────────────────────────────────────────────────────── + +interface DistrictSchool { + id: string; + name: string; + schoolType: string; + country: string; + _count?: { + students: number; + classGroups: number; + users: number; + }; +} + +interface District { + id: string; + name: string; + code: string | null; + region: string | null; + country: string; + adminEmail: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; + schools: DistrictSchool[]; + schoolCount?: number; + totalStudents?: number; +} + +interface AllSchool { + id: string; + name: string; + schoolType: string; + country: string; + districtId: string | null; + _count?: { + students: number; + classGroups: number; + users: number; + }; +} + +interface ActivityItem { + id: string; + type: 'created' | 'updated' | 'school_added' | 'school_removed'; + description: string; + timestamp: string; +} + +// ── Color palette for charts ─────────────────────────────────────────── + +const CHART_COLORS = [ + '#10b981', '#14b8a6', '#059669', '#0d9488', '#047857', + '#06b6d4', '#0891b2', '#0e7490', '#22d3ee', '#67e8f9', +]; + +// ── Helper functions ─────────────────────────────────────────────────── + +function getSchoolTypeLabel(schoolType: string): string { + switch (schoolType) { + case 'ELEMENTARY': return 'Grundschule'; + case 'MIDDLE': return 'Mittelschule'; + case 'GYMNASIUM': return 'Gymnasium'; + case 'OTHER': return 'Andere'; + default: return schoolType; + } +} + +function formatDate(dateStr: string): string { + try { + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } catch { + return dateStr; + } +} + +function formatRelativeTime(dateStr: string): string { + try { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return formatDate(dateStr); + } catch { + return dateStr; + } +} + +// ── Component ────────────────────────────────────────────────────────── + +export default function DistrictManagementView() { + const currentUser = useAppStore((s) => s.currentUser); + + // Data state + const [districts, setDistricts] = useState([]); + const [allSchools, setAllSchools] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedDistrict, setSelectedDistrict] = useState(null); + const [districtDetail, setDistrictDetail] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + // Dialog state + const [showFormDialog, setShowFormDialog] = useState(false); + const [editingDistrict, setEditingDistrict] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [deletingDistrict, setDeletingDistrict] = useState(null); + const [showAssignDialog, setShowAssignDialog] = useState(false); + + // Form state + const [formName, setFormName] = useState(''); + const [formCode, setFormCode] = useState(''); + const [formRegion, setFormRegion] = useState(''); + const [formCountry, setFormCountry] = useState('DE'); + const [formAdminEmail, setFormAdminEmail] = useState(''); + const [formSubmitting, setFormSubmitting] = useState(false); + + // Assign school form + const [selectedSchoolId, setSelectedSchoolId] = useState(''); + + // Activity log + const [activityLog, setActivityLog] = useState([]); + + // Permission check + const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + // ── Data loading ───────────────────────────────────────────────────── + + const loadDistricts = useCallback(async () => { + try { + setLoading(true); + const data = await apiGet('/api/districts'); + setDistricts(data); + } catch (error) { + console.error('Failed to load districts:', error); + toast.error('Failed to load districts'); + } finally { + setLoading(false); + } + }, []); + + const loadAllSchools = useCallback(async () => { + try { + const data = await apiGet('/api/schools'); + setAllSchools(data); + } catch (error) { + console.error('Failed to load schools:', error); + } + }, []); + + const loadDistrictDetail = useCallback(async (districtId: string) => { + try { + const data = await apiGet(`/api/districts/${districtId}`); + setDistrictDetail(data); + } catch (error) { + console.error('Failed to load district detail:', error); + } + }, []); + + useEffect(() => { + loadDistricts(); + loadAllSchools(); + }, [loadDistricts, loadAllSchools]); + + // Load detail when a district is selected + useEffect(() => { + if (selectedDistrict) { + loadDistrictDetail(selectedDistrict.id); + } else { + setDistrictDetail(null); + } + }, [selectedDistrict, loadDistrictDetail]); + + // ── Computed data ──────────────────────────────────────────────────── + + const filteredDistricts = useMemo(() => { + if (!searchQuery.trim()) return districts; + const q = searchQuery.toLowerCase(); + return districts.filter( + (d) => + d.name.toLowerCase().includes(q) || + (d.code && d.code.toLowerCase().includes(q)) || + (d.region && d.region.toLowerCase().includes(q)) || + d.country.toLowerCase().includes(q) + ); + }, [districts, searchQuery]); + + const unassignedSchools = useMemo(() => { + return allSchools.filter((s) => !s.districtId); + }, [allSchools]); + + const districtSchools = useMemo(() => { + if (!districtDetail?.schools) return []; + return districtDetail.schools; + }, [districtDetail]); + + // Aggregated stats + const totalStats = useMemo(() => { + const totalSchools = districts.reduce((acc, d) => acc + (d.schools?.length ?? d.schoolCount ?? 0), 0); + const totalStudents = districts.reduce((acc, d) => { + const studentCount = d.schools?.reduce((sa, s) => sa + (s._count?.students ?? 0), 0) ?? d.totalStudents ?? 0; + return acc + studentCount; + }, 0); + const totalTeachers = districts.reduce((acc, d) => { + const teacherCount = d.schools?.reduce((sa, s) => sa + (s._count?.users ?? 0), 0) ?? 0; + return acc + teacherCount; + }, 0); + return { totalSchools, totalStudents, totalTeachers, totalDistricts: districts.length }; + }, [districts]); + + // Performance comparison data for chart + const performanceComparisonData = useMemo(() => { + if (!districtDetail?.schools) return []; + return districtDetail.schools.map((school, index) => ({ + name: school.name.length > 15 ? school.name.substring(0, 15) + '...' : school.name, + fullName: school.name, + students: school._count?.students ?? 0, + teachers: school._count?.users ?? 0, + classes: school._count?.classGroups ?? 0, + performance: Math.round(60 + Math.random() * 35), // Simulated performance score + attendance: Math.round(85 + Math.random() * 14), // Simulated attendance rate + fill: CHART_COLORS[index % CHART_COLORS.length], + })); + }, [districtDetail]); + + // Radar chart data + const radarData = useMemo(() => { + if (!districtDetail?.schools || districtDetail.schools.length === 0) return []; + const metrics = [ + { metric: t('district.student_count'), ...Object.fromEntries(districtDetail.schools.slice(0, 5).map((s, i) => [`school${i}`, s._count?.students ?? 0])) }, + { metric: t('district.teacher_count'), ...Object.fromEntries(districtDetail.schools.slice(0, 5).map((s, i) => [`school${i}`, s._count?.users ?? 0])) }, + { metric: t('district.total_classes'), ...Object.fromEntries(districtDetail.schools.slice(0, 5).map((s, i) => [`school${i}`, s._count?.classGroups ?? 0])) }, + ]; + return metrics; + }, [districtDetail]); + + // ── Activity log ───────────────────────────────────────────────────── + + const addActivity = useCallback((type: ActivityItem['type'], description: string) => { + const item: ActivityItem = { + id: Date.now().toString() + Math.random().toString(36).slice(2), + type, + description, + timestamp: new Date().toISOString(), + }; + setActivityLog((prev) => [item, ...prev].slice(0, 20)); + }, []); + + // ── CRUD handlers ──────────────────────────────────────────────────── + + const handleOpenCreate = useCallback(() => { + setEditingDistrict(null); + setFormName(''); + setFormCode(''); + setFormRegion(''); + setFormCountry('DE'); + setFormAdminEmail(''); + setShowFormDialog(true); + }, []); + + const handleOpenEdit = useCallback((district: District) => { + setEditingDistrict(district); + setFormName(district.name); + setFormCode(district.code ?? ''); + setFormRegion(district.region ?? ''); + setFormCountry(district.country); + setFormAdminEmail(district.adminEmail ?? ''); + setShowFormDialog(true); + }, []); + + const handleFormSubmit = useCallback(async () => { + if (!formName.trim()) { + toast.error('Name is required'); + return; + } + try { + setFormSubmitting(true); + if (editingDistrict) { + await apiPut(`/api/districts/${editingDistrict.id}`, { + name: formName, + code: formCode || null, + region: formRegion || null, + country: formCountry, + adminEmail: formAdminEmail || null, + }); + addActivity('updated', `${t('district.activity_updated')}: ${formName}`); + toast.success(t('district.save_success')); + } else { + await apiPost('/api/districts', { + name: formName, + code: formCode || null, + region: formRegion || null, + country: formCountry, + adminEmail: formAdminEmail || null, + }); + addActivity('created', `${t('district.activity_created')}: ${formName}`); + toast.success(t('district.save_success')); + } + setShowFormDialog(false); + await loadDistricts(); + if (selectedDistrict) { + await loadDistrictDetail(selectedDistrict.id); + } + } catch (error) { + console.error('Failed to save district:', error); + toast.error('Failed to save district'); + } finally { + setFormSubmitting(false); + } + }, [editingDistrict, formName, formCode, formRegion, formCountry, formAdminEmail, loadDistricts, loadDistrictDetail, selectedDistrict, addActivity]); + + const handleDelete = useCallback(async () => { + if (!deletingDistrict) return; + try { + await apiDelete(`/api/districts/${deletingDistrict.id}`); + addActivity('school_removed', `${t('district.delete')}: ${deletingDistrict.name}`); + toast.success(t('district.delete_success')); + setShowDeleteDialog(false); + setDeletingDistrict(null); + if (selectedDistrict?.id === deletingDistrict.id) { + setSelectedDistrict(null); + } + await loadDistricts(); + } catch (error) { + console.error('Failed to delete district:', error); + toast.error('Failed to delete district'); + } + }, [deletingDistrict, selectedDistrict, loadDistricts, addActivity]); + + const handleAssignSchool = useCallback(async () => { + if (!selectedDistrict || !selectedSchoolId) return; + try { + await apiPost(`/api/districts/${selectedDistrict.id}/schools`, { + schoolId: selectedSchoolId, + }); + addActivity('school_added', `${t('district.activity_school_added')}`); + toast.success(t('district.school_assigned')); + setShowAssignDialog(false); + setSelectedSchoolId(''); + await loadDistricts(); + await loadDistrictDetail(selectedDistrict.id); + await loadAllSchools(); + } catch (error) { + console.error('Failed to assign school:', error); + toast.error('Failed to assign school'); + } + }, [selectedDistrict, selectedSchoolId, loadDistricts, loadDistrictDetail, loadAllSchools, addActivity]); + + const handleRemoveSchool = useCallback(async (schoolId: string, schoolName: string) => { + if (!selectedDistrict) return; + try { + await apiPut(`/api/schools`, { + id: schoolId, + districtId: null, + }); + addActivity('school_removed', `${t('district.activity_school_removed')}: ${schoolName}`); + toast.success(t('district.school_removed')); + await loadDistricts(); + await loadDistrictDetail(selectedDistrict.id); + await loadAllSchools(); + } catch (error) { + console.error('Failed to remove school:', error); + toast.error('Failed to remove school'); + } + }, [selectedDistrict, loadDistricts, loadDistrictDetail, loadAllSchools, addActivity]); + + // ── Render ─────────────────────────────────────────────────────────── + + if (!isAdmin) { + return ( +
+
+ +

Access Denied

+

You need admin privileges to access this view.

+
+
+ ); + } + + if (loading) { + return ( +
+
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ + +
+
+ ); + } + + // ── Detail View ────────────────────────────────────────────────────── + + if (selectedDistrict && districtDetail) { + const detailSchools = districtDetail.schools ?? []; + const detailStudentCount = detailSchools.reduce((acc, s) => acc + (s._count?.students ?? 0), 0); + const detailTeacherCount = detailSchools.reduce((acc, s) => acc + (s._count?.users ?? 0), 0); + const detailClassCount = detailSchools.reduce((acc, s) => acc + (s._count?.classGroups ?? 0), 0); + + return ( + + {/* Header */} +
+ +
+
+ +
+
+

{districtDetail.name}

+
+ {districtDetail.code && {districtDetail.code}} + {districtDetail.region && ( + + + {districtDetail.region} + + )} + + {districtDetail.isActive ? t('district.active') : t('district.inactive')} + +
+
+
+
+ + +
+
+ + {/* Stats Cards */} +
+ {[ + { icon: School, label: t('district.school_count'), value: detailSchools.length, color: 'from-emerald-400 to-teal-500' }, + { icon: GraduationCap, label: t('district.student_count'), value: detailStudentCount, color: 'from-teal-400 to-cyan-500' }, + { icon: UserCheck, label: t('district.teacher_count'), value: detailTeacherCount, color: 'from-cyan-400 to-sky-500' }, + { icon: Users, label: t('district.total_classes'), value: detailClassCount, color: 'from-sky-400 to-blue-500' }, + ].map((stat, i) => ( + + + +
+
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+
+
+
+ ))} +
+ + {/* District Info Card */} + + + + + {t('district.district_info')} + + + +
+
+ {t('district.name')} +

{districtDetail.name}

+
+
+ {t('district.code')} +

{districtDetail.code || '-'}

+
+
+ {t('district.region')} +

{districtDetail.region || '-'}

+
+
+ {t('district.admin_email')} +

+ {districtDetail.adminEmail ? ( + <> + + {districtDetail.adminEmail} + + ) : '-'} +

+
+
+
+
+ + {/* Tabs */} + + + + + {t('district.schools')} + + + + {t('district.analytics')} + + + + {t('district.recent_activity')} + + + + {/* Schools Tab */} + +
+

+ {t('district.schools')} ({detailSchools.length}) +

+ +
+ + {detailSchools.length === 0 ? ( + + + +

{t('district.no_schools_assigned')}

+ +
+
+ ) : ( + + + + + + {t('district.name')} + {t('district.school_type')} + {t('district.student_count')} + {t('district.teacher_count')} + {t('district.total_classes')} + + + + + {detailSchools.map((school, i) => ( + + +
+ + {school.name} +
+
+ + {getSchoolTypeLabel(school.schoolType)} + + {school._count?.students ?? 0} + {school._count?.users ?? 0} + {school._count?.classGroups ?? 0} + + + +
+ ))} +
+
+
+
+ )} +
+ + {/* Analytics Tab */} + + {detailSchools.length === 0 ? ( + + + +

{t('district.no_schools_assigned')}

+
+
+ ) : ( + <> + {/* Performance Comparison Bar Chart */} + + + + + {t('district.performance_comparison')} + + {t('district.cross_school_comparison')} + + +
+ + + + + + + + + + + + +
+
+
+ + {/* Radar Chart - Performance & Attendance */} +
+ + + + + {t('district.avg_performance')} + + + +
+ + + + + + + + + +
+
+
+ + + + + + {t('district.attendance_rate')} + + + +
+ + + + + + + + + +
+
+
+
+ + {/* Summary Stats */} + + + + + {t('district.overview_stats')} + + + +
+
+

{detailStudentCount}

+

{t('district.total_students')}

+
+
+

{detailTeacherCount}

+

{t('district.total_teachers')}

+
+
+

{detailClassCount}

+

{t('district.total_classes')}

+
+
+

+ {detailTeacherCount > 0 ? (detailStudentCount / detailTeacherCount).toFixed(1) : '0'} +

+

{t('district.student_teacher_ratio')}

+
+
+
+
+ + )} +
+ + {/* Activity Tab */} + + + + + + {t('district.recent_activity')} + + + + {activityLog.length === 0 ? ( +
+ +

No recent activity

+
+ ) : ( + +
+ {activityLog.map((item) => ( +
+
+ {item.type === 'created' && } + {item.type === 'updated' && } + {item.type === 'school_added' && } + {item.type === 'school_removed' && } +
+
+

{item.description}

+

{formatRelativeTime(item.timestamp)}

+
+
+ ))} +
+
+ )} +
+
+
+
+ + {/* Assign School Dialog */} + + + + + + {t('district.assign_school')} + + + {t('district.select_school')} + + +
+ {unassignedSchools.length === 0 ? ( +
+ +

{t('district.no_unassigned_schools')}

+
+ ) : ( + + )} +
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + + + {t('district.confirm_delete')} + + + {t('district.confirm_delete_desc')} + + + + {t('action.cancel')} + + {t('action.delete')} + + + + +
+ ); + } + + // ── Overview (List) View ───────────────────────────────────────────── + + return ( + + {/* Header */} +
+
+
+ +
+
+

{t('district.management')}

+

{t('district.overview')}

+
+
+ +
+ + {/* Summary Stats */} +
+ {[ + { icon: Building2, label: t('district.title'), value: totalStats.totalDistricts, color: 'from-emerald-400 to-teal-500' }, + { icon: School, label: t('district.school_count'), value: totalStats.totalSchools, color: 'from-teal-400 to-cyan-500' }, + { icon: GraduationCap, label: t('district.student_count'), value: totalStats.totalStudents, color: 'from-cyan-400 to-sky-500' }, + { icon: UserCheck, label: t('district.teacher_count'), value: totalStats.totalTeachers, color: 'from-sky-400 to-blue-500' }, + ].map((stat, i) => ( + + + +
+
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+
+
+
+ ))} +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 border-emerald-200 dark:border-emerald-800 focus:ring-emerald-500/20" + /> +
+ + {/* District Cards */} + {filteredDistricts.length === 0 ? ( + + + +

{t('district.no_districts')}

+

{t('district.no_districts_desc')}

+ +
+
+ ) : ( +
+ + {filteredDistricts.map((district, i) => { + const schoolCount = district.schools?.length ?? district.schoolCount ?? 0; + const studentCount = district.schools?.reduce((acc, s) => acc + (s._count?.students ?? 0), 0) ?? district.totalStudents ?? 0; + const teacherCount = district.schools?.reduce((acc, s) => acc + (s._count?.users ?? 0), 0) ?? 0; + + return ( + + setSelectedDistrict(district)} + > + +
+
+
+ +
+
+ + {district.name} + +
+ {district.code && ( + {district.code} + )} + + {district.isActive ? t('district.active') : t('district.inactive')} + +
+
+
+ +
+
+ +
+ {/* Location info */} + {(district.region || district.country) && ( +
+ + {[district.region, district.country].filter(Boolean).join(', ')} +
+ )} + {/* Admin email */} + {district.adminEmail && ( +
+ + {district.adminEmail} +
+ )} + {/* Stats */} +
+
+ + {schoolCount} + {t('district.schools')} +
+
+ + {studentCount} + {t('district.student_count')} +
+
+ + {teacherCount} + {t('district.teacher_count')} +
+
+
+ {/* Action buttons */} +
+ + +
+
+
+
+ ); + })} +
+
+ )} + + {/* Create/Edit District Dialog */} + + + + + + {editingDistrict ? t('district.edit_district') : t('district.add_district')} + + + {editingDistrict ? t('district.edit') : t('district.create')} + + +
+
+ + setFormName(e.target.value)} + placeholder={t('district.name')} + className="border-emerald-200 dark:border-emerald-800 focus:ring-emerald-500/20" + /> +
+
+ + setFormCode(e.target.value)} + placeholder={t('district.code')} + className="border-emerald-200 dark:border-emerald-800 focus:ring-emerald-500/20" + /> +
+
+
+ + setFormRegion(e.target.value)} + placeholder={t('district.region')} + className="border-emerald-200 dark:border-emerald-800 focus:ring-emerald-500/20" + /> +
+
+ + setFormCountry(e.target.value)} + placeholder={t('district.country')} + className="border-emerald-200 dark:border-emerald-800 focus:ring-emerald-500/20" + /> +
+
+
+ + setFormAdminEmail(e.target.value)} + placeholder={t('district.admin_email')} + className="border-emerald-200 dark:border-emerald-800 focus:ring-emerald-500/20" + /> +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + + + {t('district.confirm_delete')} + + + {t('district.confirm_delete_desc')} + + + + {t('action.cancel')} + + {t('action.delete')} + + + + +
+ ); +} diff --git a/src/components/student-portal-view.tsx b/src/components/student-portal-view.tsx new file mode 100644 index 0000000..e421332 --- /dev/null +++ b/src/components/student-portal-view.tsx @@ -0,0 +1,1481 @@ +'use client'; + +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Target, + Trophy, + BookOpen, + Calendar, + Clock, + Star, + Award, + CheckCircle2, + Plus, + Flame, + Zap, + CircleDot, + Trash2, + Edit3, + Save, + AlertCircle, + ClipboardCheck, + MessageSquare, + Flower2, + Calculator, + Globe, + BookMarked, + CalendarDays, + TrendingUp, + Sparkles, + ChevronRight, + Lock, + Unlock, + PartyPopper, + Sunrise, + Sun, + Moon, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; + +import { useAppStore } from '@/lib/store'; +import { t } from '@/lib/i18n'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; +import { toast } from 'sonner'; + +// ── Types ────────────────────────────────────────────────────────────── + +interface StudentGoal { + id: string; + studentId: string; + title: string; + description: string | null; + targetDate: string | null; + progress: number; + status: string; + createdAt: string; + updatedAt: string; +} + +interface CompetencyData { + subject: string; + level: number; + progress: number; +} + +interface BadgeData { + id: string; + name: string; + description: string; + icon: string; + earnedAt: string | null; +} + +interface SchedulePeriod { + period: number; + subject: string; + room: string; + startTime: string; + endTime: string; + isCurrent: boolean; + isNext: boolean; +} + +interface HomeworkItem { + id: string; + title: string; + subject: string; + dueDate: string; + status: string; +} + +interface FeedbackItem { + id: string; + teacherName: string; + subject: string; + comment: string; + date: string; + competencyLevel?: number; +} + +// ── Animation Variants ────────────────────────────────────────────────── + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.06 }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: 'easeOut' } }, +}; + +const tabContentVariants = { + hidden: { opacity: 0, x: 12 }, + visible: { opacity: 1, x: 0, transition: { duration: 0.3, ease: 'easeOut' } }, + exit: { opacity: 0, x: -12, transition: { duration: 0.2, ease: 'easeIn' } }, +}; + +// ── Mastery Level Colors ──────────────────────────────────────────────── + +const masteryColors: Record = { + 1: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400', border: 'border-rose-200 dark:border-rose-800', gradient: 'from-rose-400 to-rose-500', ring: 'stroke-rose-400' }, + 2: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200 dark:border-amber-800', gradient: 'from-amber-400 to-amber-500', ring: 'stroke-amber-400' }, + 3: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200 dark:border-emerald-800', gradient: 'from-emerald-400 to-emerald-500', ring: 'stroke-emerald-400' }, + 4: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200 dark:border-teal-800', gradient: 'from-teal-400 to-teal-500', ring: 'stroke-teal-400' }, +}; + +const subjectIcons: Record = { + Mathematik: Calculator, + Deutsch: BookOpen, + Englisch: Globe, + Naturwissenschaften: Flower2, + Mathematics: Calculator, + German: BookOpen, + English: Globe, + Sciences: Flower2, +}; + +// ── Animated Number Component ──────────────────────────────────────────── + +function AnimatedNumber({ value, duration = 800 }: { value: number; duration?: number }) { + const [displayValue, setDisplayValue] = useState(0); + const prevValue = useRef(0); + + useEffect(() => { + const start = prevValue.current; + const end = value; + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setDisplayValue(Math.round(start + (end - start) * eased)); + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + prevValue.current = value; + }, [value, duration]); + + return {displayValue}; +} + +// ── Circular Progress Component ───────────────────────────────────────── + +function CircularProgress({ + value, + size = 80, + strokeWidth = 6, + colorClass = 'stroke-emerald-400', + children, +}: { + value: number; + size?: number; + strokeWidth?: number; + colorClass?: string; + children?: React.ReactNode; +}) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (value / 100) * circumference; + + return ( +
+ + + + +
+ {children} +
+
+ ); +} + +// ── Celebration Particles ──────────────────────────────────────────────── + +function CelebrationParticles({ active }: { active: boolean }) { + if (!active) return null; + + const particles = Array.from({ length: 12 }, (_, i) => ({ + id: i, + angle: (i * 30) * (Math.PI / 180), + distance: 30 + Math.random() * 20, + color: ['bg-amber-400', 'bg-emerald-400', 'bg-rose-400', 'bg-violet-400', 'bg-teal-400'][i % 5], + size: 4 + Math.random() * 4, + delay: Math.random() * 0.3, + })); + + return ( +
+ {particles.map((p) => ( + + ))} +
+ ); +} + +// ── Component ─────────────────────────────────────────────────────────── + +export default function StudentPortalView() { + const { currentUser, locale } = useAppStore(); + const [goals, setGoals] = useState([]); + const [isLoadingGoals, setIsLoadingGoals] = useState(true); + const [showGoalDialog, setShowGoalDialog] = useState(false); + const [editingGoal, setEditingGoal] = useState(null); + const [goalForm, setGoalForm] = useState({ title: '', description: '', targetDate: '' }); + const [activeTab, setActiveTab] = useState('competencies'); + const [celebratingGoals, setCelebratingGoals] = useState>(new Set()); + + // ── Mock data for demo ───────────────────────────────────────────── + + const competencies: CompetencyData[] = [ + { subject: t('student.notebook_math'), level: 2, progress: 72 }, + { subject: t('student.notebook_german'), level: 3, progress: 85 }, + { subject: t('student.notebook_english'), level: 2, progress: 58 }, + { subject: t('student.notebook_science'), level: 1, progress: 44 }, + { subject: 'Musik', level: 3, progress: 90 }, + { subject: 'Sport', level: 4, progress: 95 }, + ]; + + const achievements: BadgeData[] = [ + { id: '1', name: 'First Steps', description: 'Complete your first competency', icon: 'star', earnedAt: new Date(Date.now() - 86400000 * 14).toISOString() }, + { id: '2', name: 'Bookworm', description: 'Complete 10 notebook pages', icon: 'book', earnedAt: new Date(Date.now() - 86400000 * 7).toISOString() }, + { id: '3', name: 'Streak Master', description: '7-day learning streak', icon: 'flame', earnedAt: new Date(Date.now() - 86400000 * 2).toISOString() }, + { id: '4', name: 'Competency King', description: 'Reach level 3 in any subject', icon: 'crown', earnedAt: null }, + { id: '5', name: 'Perfect Score', description: 'Score 100% on an assessment', icon: 'trophy', earnedAt: null }, + ]; + + const now = new Date(); + const currentHour = now.getHours(); + const dayOfWeek = now.getDay(); + + const schedule: SchedulePeriod[] = dayOfWeek >= 1 && dayOfWeek <= 5 ? [ + { period: 1, subject: t('student.notebook_math'), room: 'R101', startTime: '08:00', endTime: '08:45', isCurrent: currentHour === 8, isNext: currentHour === 7 }, + { period: 2, subject: t('student.notebook_german'), room: 'R205', startTime: '08:50', endTime: '09:35', isCurrent: currentHour === 8 || (currentHour === 9 && now.getMinutes() < 35), isNext: currentHour === 8 && now.getMinutes() > 45 }, + { period: 3, subject: t('student.notebook_english'), room: 'R103', startTime: '09:55', endTime: '10:40', isCurrent: false, isNext: false }, + { period: 4, subject: t('student_portal.break'), room: '', startTime: '10:40', endTime: '11:00', isCurrent: false, isNext: false }, + { period: 5, subject: t('student.notebook_science'), room: 'Lab1', startTime: '11:00', endTime: '11:45', isCurrent: false, isNext: false }, + { period: 6, subject: 'Musik', room: 'M101', startTime: '11:50', endTime: '12:35', isCurrent: false, isNext: false }, + ] : []; + + // Mark current and next properly + const currentPeriodIndex = schedule.findIndex(p => { + const [sh, sm] = p.startTime.split(':').map(Number); + const [eh, em] = p.endTime.split(':').map(Number); + const startMin = sh * 60 + sm; + const endMin = eh * 60 + em; + const nowMin = currentHour * 60 + now.getMinutes(); + return nowMin >= startMin && nowMin < endMin; + }); + + const homework: HomeworkItem[] = [ + { id: '1', title: 'Math Worksheet Ch.5', subject: t('student.notebook_math'), dueDate: new Date(Date.now() + 86400000).toISOString(), status: 'pending' }, + { id: '2', title: 'Essay: Mein Lieblingstag', subject: t('student.notebook_german'), dueDate: new Date(Date.now() + 86400000 * 2).toISOString(), status: 'pending' }, + { id: '3', title: 'Vocabulary Quiz Prep', subject: t('student.notebook_english'), dueDate: new Date(Date.now() - 86400000).toISOString(), status: 'overdue' }, + { id: '4', title: 'Lab Report: Photosynthesis', subject: t('student.notebook_science'), dueDate: new Date(Date.now() + 86400000 * 5).toISOString(), status: 'pending' }, + ]; + + const feedback: FeedbackItem[] = [ + { id: '1', teacherName: 'Herr Mueller', subject: t('student.notebook_math'), comment: 'Great improvement in algebra! Keep up the good work.', date: new Date(Date.now() - 86400000 * 1).toISOString(), competencyLevel: 3 }, + { id: '2', teacherName: 'Frau Schmidt', subject: t('student.notebook_german'), comment: 'Excellent essay structure. Your argumentation is getting stronger.', date: new Date(Date.now() - 86400000 * 3).toISOString(), competencyLevel: 3 }, + { id: '3', teacherName: 'Mr. Brown', subject: t('student.notebook_english'), comment: 'Good vocabulary usage. Focus on pronunciation next.', date: new Date(Date.now() - 86400000 * 5).toISOString(), competencyLevel: 2 }, + ]; + + // ── Goals API ────────────────────────────────────────────────────── + + const fetchGoals = useCallback(async () => { + if (!currentUser) return; + try { + setIsLoadingGoals(true); + const studentData = await apiGet<{ id: string }[]>(`/api/students?schoolId=${currentUser.schoolId}&userId=${currentUser.id}`); + if (studentData && studentData.length > 0) { + const studentId = studentData[0].id; + const data = await apiGet(`/api/student-goals?studentId=${studentId}`); + setGoals(data); + } + } catch { + // Silently fail for demo + } finally { + setIsLoadingGoals(false); + } + }, [currentUser]); + + useEffect(() => { + fetchGoals(); + }, [fetchGoals]); + + const handleCreateGoal = async () => { + if (!goalForm.title.trim()) { + toast.error(t('student_portal.goal_title')); + return; + } + try { + const studentData = await apiGet<{ id: string }[]>(`/api/students?schoolId=${currentUser?.schoolId}&userId=${currentUser?.id}`); + if (studentData && studentData.length > 0) { + const studentId = studentData[0].id; + if (editingGoal) { + await apiPut(`/api/student-goals/${editingGoal.id}`, { + title: goalForm.title, + description: goalForm.description, + targetDate: goalForm.targetDate || null, + }); + toast.success(t('student_portal.goal_progress')); + } else { + await apiPost('/api/student-goals', { + studentId, + title: goalForm.title, + description: goalForm.description, + targetDate: goalForm.targetDate || null, + }); + toast.success(t('student_portal.goal_set')); + } + setShowGoalDialog(false); + setEditingGoal(null); + setGoalForm({ title: '', description: '', targetDate: '' }); + fetchGoals(); + } + } catch { + toast.error('Error saving goal'); + } + }; + + const handleUpdateGoalProgress = async (goalId: string, progress: number) => { + try { + await apiPut(`/api/student-goals/${goalId}`, { progress }); + if (progress >= 100) { + toast.success(t('student_portal.goal_complete')); + setCelebratingGoals(prev => new Set(prev).add(goalId)); + setTimeout(() => { + setCelebratingGoals(prev => { + const next = new Set(prev); + next.delete(goalId); + return next; + }); + }, 2000); + } + fetchGoals(); + } catch { + toast.error('Error updating progress'); + } + }; + + const handleDeleteGoal = async (goalId: string) => { + try { + await apiDelete(`/api/student-goals/${goalId}`); + toast.success(t('action.delete')); + fetchGoals(); + } catch { + toast.error('Error deleting goal'); + } + }; + + const openEditGoal = (goal: StudentGoal) => { + setEditingGoal(goal); + setGoalForm({ + title: goal.title, + description: goal.description || '', + targetDate: goal.targetDate ? new Date(goal.targetDate).toISOString().split('T')[0] : '', + }); + setShowGoalDialog(true); + }; + + const openNewGoal = () => { + setEditingGoal(null); + setGoalForm({ title: '', description: '', targetDate: '' }); + setShowGoalDialog(true); + }; + + // ── Derived Data ─────────────────────────────────────────────────── + + const activeGoals = goals.filter(g => g.status === 'active'); + const completedGoals = goals.filter(g => g.status === 'completed'); + const earnedBadges = achievements.filter(a => a.earnedAt); + const totalPoints = earnedBadges.length * 50 + Math.floor(competencies.reduce((sum, c) => sum + c.progress, 0) / 10); + const overdueHomework = homework.filter(h => h.status === 'overdue'); + const upcomingHomework = homework.filter(h => h.status !== 'overdue'); + const daysUntil = (dateStr: string) => { + const diff = Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + return diff; + }; + + // Greeting based on time of day + const getGreeting = () => { + const hour = new Date().getHours(); + if (hour < 12) return t('student_portal.good_morning'); + if (hour < 17) return t('student_portal.good_afternoon'); + return t('student_portal.good_evening'); + }; + + const getGreetingIcon = () => { + const hour = new Date().getHours(); + if (hour < 12) return Sunrise; + if (hour < 17) return Sun; + return Moon; + }; + + // Streak calculation (mock) + const streakDays = 7; + const maxLevel = 4; + const averageProgress = competencies.length > 0 + ? Math.round(competencies.reduce((sum, c) => sum + c.progress, 0) / competencies.length) + : 0; + const overallLevel = competencies.length > 0 + ? Math.round(competencies.reduce((sum, c) => sum + c.level, 0) / competencies.length * 10) / 10 + : 0; + const levelUpProgress = Math.round((overallLevel / maxLevel) * 100); + + // ── Render ───────────────────────────────────────────────────────── + + const greetingHour = new Date().getHours(); + + return ( + + {/* ── Hero Section ──────────────────────────────────────────────── */} + +
+ {/* Decorative elements */} +
+
+
+ + + +
+ +
+
+
+ {greetingHour < 12 ? : greetingHour < 17 ? : } +
+
+

+ {getGreeting()}, {currentUser?.firstName || t('student_portal.title')}! +

+

+ {t('student_portal.hero_message')} +

+
+
+
+
+ + + {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+ + {/* Quick Stats Bar inside hero */} +
+ {[ + { label: t('student_portal.points_earned'), value: totalPoints, icon: Zap, color: 'bg-white/15' }, + { label: t('student_portal.streak_days'), value: streakDays, icon: Flame, color: 'bg-white/15' }, + { label: t('student_portal.badges_earned'), value: earnedBadges.length, icon: Award, color: 'bg-white/15' }, + { label: t('student_portal.active_goals'), value: activeGoals.length, icon: Target, color: 'bg-white/15' }, + ].map((stat) => ( + + +
+

+ +

+

{stat.label}

+
+
+ ))} +
+
+ + + {/* ── Level Up Progress Bar ──────────────────────────────────────── */} + + + +
+
+
+ +
+
+

{t('student_portal.level_up_progress')}

+

{t('student_portal.level_up_detail', { level: overallLevel, max: maxLevel })}

+
+
+
+ {levelUpProgress}% + +
+
+
+ +
+
+ + +
+ + {/* ── Streak Counter ──────────────────────────────────────────────── */} + + + +
+
+ + + +
+
+
+

{t('student_portal.learning_streak')}

+ + {streakDays} {t('student_portal.days')} + +
+

{t('student_portal.streak_message')}

+
+ {Array.from({ length: 7 }).map((_, i) => ( + + {i + 1} + + ))} +
+ 8 +
+
+
+
+
+
+
+ + {/* ── Main Tabs ──────────────────────────────────────────────────── */} + + + + + + {t('student_portal.my_competencies')} + {t('student_portal.my_competencies').split(' ')[0]} + + + + {t('student_portal.my_goals')} + {t('student_portal.my_goals').split(' ')[0]} + + + + {t('student_portal.my_achievements')} + {t('student_portal.my_achievements').split(' ')[0]} + + + + {t('student_portal.my_schedule')} + {t('student_portal.my_schedule').split(' ')[0]} + + + + {t('student_portal.my_homework')} + {t('student_portal.my_homework').split(' ')[0]} + + + + {t('student_portal.my_feedback')} + {t('student_portal.my_feedback').split(' ')[0]} + + + + {/* ── My Competencies Tab ──────────────────────────────────── */} + + + + {competencies.map((comp) => { + const colors = masteryColors[comp.level] || masteryColors[1]; + const levelLabel = t(`student_portal.level_${comp.level}`); + const SubjectIcon = subjectIcons[comp.subject] || BookOpen; + return ( + + +
+ +
+
+
+ +
+
+ {comp.subject} +

{t('student_portal.mastery_level')}

+
+
+ + {t('student_portal.level')} {comp.level} — {levelLabel} + +
+
+ +
+ {/* Circular progress */} +
+ + {comp.progress}% + +
+
+ {t('student_portal.progress_bar')} + {comp.progress}% +
+ +
+
+ {/* Level dots */} +
+ {[1, 2, 3, 4].map((lvl) => ( + + {lvl} + + ))} +
+
+
+ + + ); + })} + + + {competencies.length === 0 && ( +
+
+ +
+

{t('student_portal.no_competencies')}

+
+ )} + + + {/* ── My Goals Tab ─────────────────────────────────────────── */} + + + + {/* Add Goal Button */} +
+
+ + {activeGoals.length} {t('student_portal.active_goals')} + + + {completedGoals.length} {t('student_portal.completed_goals')} + +
+ +
+ + {/* Active Goals */} + {isLoadingGoals ? ( +
+ {[1, 2].map(i => ( +
+ ))} +
+ ) : activeGoals.length > 0 ? ( +
+ + {activeGoals.map((goal) => { + const isCompleted = goal.progress >= 100; + const isCelebrating = celebratingGoals.has(goal.id); + const progressColor = isCompleted + ? 'from-emerald-400 to-teal-500' + : goal.progress >= 75 + ? 'from-amber-400 to-orange-500' + : 'from-rose-400 to-pink-500'; + return ( + + + + +
+
+
+ {isCompleted ? : } +
+
+

{goal.title}

+ {goal.description && ( +

{goal.description}

+ )} + {goal.targetDate && ( +

+ + {new Date(goal.targetDate).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} +

+ )} +
+
+
+ + +
+
+
+
+ {t('student_portal.goal_progress')} + {goal.progress}% +
+
+ +
+
+ + + +
+
+
+
+
+ ); + })} +
+
+ ) : ( +
+
+ +
+

{t('student_portal.no_goals')}

+ +
+ )} + + {/* Completed Goals */} + {completedGoals.length > 0 && ( +
+

+ + {t('student_portal.completed_goals')} +

+ {completedGoals.map((goal) => ( + + +
+
+ +
+
+

{goal.title}

+

{t('student_portal.goal_complete')}

+
+ +
+
+
+ ))} +
+ )} + + + + + {/* ── My Achievements Tab ──────────────────────────────────── */} + + + + {/* Points Summary */} + + +
+
+
+ +
+
+

+ +

+

{t('student_portal.total_points')}

+
+
+
+
+

{earnedBadges.length}

+

{t('student_portal.badges_earned')}

+
+
+

3

+

{t('student_portal.rewards_available')}

+
+
+
+
+
+ + {/* Badge Grid */} +
+ {achievements.map((badge, idx) => { + const isEarned = !!badge.earnedAt; + const badgeIcon = (() => { + switch (badge.icon) { + case 'star': return Star; + case 'book': return BookOpen; + case 'flame': return Flame; + case 'crown': return Award; + case 'trophy': return Trophy; + default: return CircleDot; + } + })(); + return ( + +
+ +
+

+ {badge.name} +

+

{badge.description}

+ {isEarned && ( + +
+ + {new Date(badge.earnedAt!).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })} +
+
+ )} + {!isEarned && ( +
+ +
+ )} +
+ ); + })} +
+ + {achievements.length === 0 && ( +
+
+ +
+

{t('student_portal.no_achievements')}

+
+ )} +
+
+
+ + {/* ── My Schedule Tab ──────────────────────────────────────── */} + + + + + +
+
+
+ +
+ {t('student_portal.today_schedule')} +
+ + {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'long', day: 'numeric', month: 'short' })} + +
+
+ + {schedule.length > 0 ? ( +
+ {/* Timeline line */} +
+ +
+ {schedule.map((period, idx) => { + const isCurrent = idx === currentPeriodIndex; + const isNext = idx === currentPeriodIndex + 1; + const isBreak = period.subject === t('student_portal.break'); + const SubjectIcon = subjectIcons[period.subject] || BookOpen; + + return ( + + {/* Timeline dot */} +
+ +
+ {isBreak ? : } +
+
+
+

+ {period.subject} +

+ {isCurrent && ( + + + {t('student_portal.current_period')} + + + )} + {isNext && ( + + {t('student_portal.next_period')} + + )} +
+

+ {period.startTime} - {period.endTime} + {period.room && ` · ${period.room}`} +

+
+
+

{t('student_portal.period')} {period.period}

+
+ + ); + })} +
+
+ ) : ( +
+
+ +
+

{t('student_portal.no_schedule')}

+
+ )} + + + + + + + {/* ── My Homework Tab ──────────────────────────────────────── */} + + + + {/* Overdue */} + {overdueHomework.length > 0 && ( +
+

+ + {t('student_portal.overdue')} ({overdueHomework.length}) +

+ {overdueHomework.map((hw) => ( + + + +
+
+ +
+
+

{hw.title}

+

{hw.subject}

+
+ + {t('student_portal.overdue')} + +
+
+
+
+ ))} +
+ )} + + {/* Upcoming */} +
+

+ + {t('student_portal.upcoming')} ({upcomingHomework.length}) +

+ {upcomingHomework.length > 0 ? ( + upcomingHomework.map((hw) => { + const days = daysUntil(hw.dueDate); + const isToday = days === 0; + const isSoon = days === 1; + const urgencyColor = isToday + ? 'from-amber-400 to-orange-500' + : isSoon + ? 'from-amber-400 to-yellow-500' + : 'from-emerald-400 to-teal-500'; + const urgencyRing = isToday + ? 'ring-amber-200/50 dark:ring-amber-800/50' + : isSoon + ? 'ring-amber-200/30 dark:ring-amber-800/30' + : 'ring-black/[0.03] dark:ring-white/[0.05]'; + + return ( + + + +
+
+ +
+
+

{hw.title}

+

{hw.subject}

+
+ + {isToday ? t('student_portal.due_today') : `${days}d`} + +
+
+
+
+ ); + }) + ) : ( +
+
+ +
+

{t('student_portal.no_homework_due')}

+
+ )} +
+
+
+
+ + {/* ── My Feedback Tab ──────────────────────────────────────── */} + + + + {feedback.length > 0 ? ( + feedback.map((fb, idx) => { + const colors = fb.competencyLevel ? (masteryColors[fb.competencyLevel] || masteryColors[1]) : null; + const initials = fb.teacherName.split(' ').map(n => n[0]).join('').slice(0, 2); + return ( + + + +
+ + + {initials} + + +
+
+
+

{fb.teacherName}

+ + {fb.subject} + +
+ + {new Date(fb.date).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })} + +
+

{fb.comment}

+ {fb.competencyLevel && ( +
+ {t('student_portal.mastery_level')}: + + {t('student_portal.level')} {fb.competencyLevel} + +
+ )} +
+
+
+
+
+ ); + }) + ) : ( +
+
+ +
+

{t('student_portal.no_feedback')}

+
+ )} +
+
+
+ + + + {/* ── Goal Dialog ──────────────────────────────────────────────── */} + + + + + + {editingGoal ? t('action.edit') : t('student_portal.add_goal')} + + + {editingGoal ? t('student_portal.update_progress') : t('student_portal.goal_set')} + + +
+
+ + setGoalForm({ ...goalForm, title: e.target.value })} + placeholder={t('student_portal.goal_title')} + className="min-h-[44px]" + /> +
+
+ +