From 2f266a21437f6fb58a0b967899625f905d149241 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 20:47:43 +0000 Subject: [PATCH] feat: Round 22 - Exam calendar, parent portal, teacher grading, deep styling - Exam calendar integration: Plan Exam button, countdown badges, exam filter - Parent portal: Children overview, illness approvals, grades, attendance - Teacher grading: Stylus annotation, AI review, bulk grading mode - Deep schlaukopf crawling: AI-powered content extraction from schlaukopf.de - Dashboard: Upcoming exams, AI tips section - Settings: AI provider config, virtual character toggle, management tabs - Layout: Favorites section, notification badges on sidebar items - Auth: VICE_PRINCIPAL demo account, feature showcase - Attendance: Illness report integration - Assessments: Plan exam button, AI test generation - Fixed schoolId auto-detect in counseling and disciplinary APIs - Added MessageSquare import to settings-view - 150+ new i18n keys --- prisma/schema.prisma | 6 +- src/app/api/calendar-events/exams/route.ts | 150 ++++ src/app/api/counseling/route.ts | 3 +- src/app/api/disciplinary-committees/route.ts | 3 +- src/app/api/grading/annotate/route.ts | 111 +++ src/app/api/parent-portal/route.ts | 195 +++++ src/app/api/subject-contents/crawl/route.ts | 188 +++++ src/components/app-layout.tsx | 51 +- src/components/assessments-view.tsx | 24 +- src/components/attendance-view.tsx | 13 + src/components/auth-view.tsx | 30 +- src/components/calendar-view.tsx | 336 +++++++- src/components/dashboard-view.tsx | 99 +++ src/components/grading-view.tsx | 330 +++++++- src/components/parent-portal-view.tsx | 846 +++++++++++++++++++ src/components/settings-view.tsx | 202 +++++ src/components/subjects-view.tsx | 93 ++ src/lib/api.ts | 23 +- src/lib/i18n.ts | 394 +++++++++ src/lib/store.ts | 3 +- worklog.md | 74 ++ 21 files changed, 3140 insertions(+), 34 deletions(-) create mode 100644 src/app/api/calendar-events/exams/route.ts create mode 100644 src/app/api/grading/annotate/route.ts create mode 100644 src/app/api/parent-portal/route.ts create mode 100644 src/app/api/subject-contents/crawl/route.ts create mode 100644 src/components/parent-portal-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8e58691..28324e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -468,6 +468,8 @@ model AssessmentResult { score Float? masteryLevelValue Int? note String? + annotationData String? // JSON: drawing annotation strokes for teacher grading + annotationImage String? // base64 PNG thumbnail of annotation assessment Assessment @relation(fields: [assessmentId], references: [id]) student Student @relation(fields: [studentId], references: [id]) @@ -1004,7 +1006,7 @@ model CalendarEvent { date DateTime startTime String? // HH:mm format, null = all day endTime String? // HH:mm format, null = all day - eventType String @default("reminder") // assessment | lesson | reminder + eventType String @default("reminder") // assessment | lesson | reminder | exam subjectId String? classGroupId String? notes String? @@ -1012,6 +1014,7 @@ model CalendarEvent { recurrencePattern String? // JSON: { type: "daily|weekly|monthly|yearly", interval: 1, endDate?: string, daysOfWeek?: number[] } recurrenceEnd DateTime? // When recurrence ends parentEventId String? // For generated instances of a recurring event + assessmentId String? // Linked assessment (for exam events) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1028,6 +1031,7 @@ model CalendarEvent { @@index([subjectId]) @@index([classGroupId]) @@index([parentEventId]) + @@index([eventType]) } // ─── Parent-Student Link ────────────────────────────────────────────── diff --git a/src/app/api/calendar-events/exams/route.ts b/src/app/api/calendar-events/exams/route.ts new file mode 100644 index 0000000..c012eca --- /dev/null +++ b/src/app/api/calendar-events/exams/route.ts @@ -0,0 +1,150 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { differenceInCalendarDays } from 'date-fns'; + +// ── GET: List upcoming exams ────────────────────────────────────────── +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 schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const classGroupId = searchParams.get('classGroupId'); + const limit = parseInt(searchParams.get('limit') ?? '20', 10); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const now = new Date(); + + const where: Record = { + schoolId, + eventType: 'exam', + date: { gte: now }, + }; + + if (classGroupId) { + where.classGroupId = classGroupId; + } + + // For students: only show exams from their classes + if (session.user?.role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { userId: session.userId }, + select: { id: true }, + }); + if (student) { + const enrollments = await db.enrollment.findMany({ + where: { studentId: student.id }, + select: { classGroupId: true }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + where.classGroupId = { in: classIds }; + } + } + + // For parents: show exams from their children's classes + if (session.user?.role === 'PARENT') { + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId: session.userId }, + select: { studentId: true }, + }); + const studentIds = parentLinks.map((l) => l.studentId); + const enrollments = await db.enrollment.findMany({ + where: { studentId: { in: studentIds } }, + select: { classGroupId: true }, + }); + const classIds = enrollments.map((e) => e.classGroupId); + where.classGroupId = { in: classIds }; + } + + const exams = await db.calendarEvent.findMany({ + where, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { date: 'asc' }, + take: limit, + }); + + // Add countdown info + const enrichedExams = exams.map((exam) => { + const daysUntil = differenceInCalendarDays(new Date(exam.date), now); + return { + ...exam, + daysUntil, + countdownLabel: daysUntil === 0 + ? 'today' + : daysUntil === 1 + ? 'tomorrow' + : daysUntil < 7 + ? `${daysUntil} days` + : `${Math.floor(daysUntil / 7)} weeks`, + }; + }); + + return NextResponse.json(enrichedExams); + } catch (error) { + console.error('Exams GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ── POST: Create an exam event ──────────────────────────────────────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const userRole = session.user?.role; + if (userRole !== 'TEACHER' && userRole !== 'SCHOOL_ADMIN' && userRole !== 'VICE_PRINCIPAL' && userRole !== 'SUPER_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + title, date, startTime, endTime, subjectId, classGroupId, + notes, assessmentId, schoolId, + } = body; + + if (!title || !date || !schoolId) { + return NextResponse.json({ error: 'title, date, and schoolId are required' }, { status: 400 }); + } + + const event = await db.calendarEvent.create({ + data: { + schoolId, + teacherId: session.user?.id ?? '', + title, + date: new Date(date), + startTime: startTime || null, + endTime: endTime || null, + eventType: 'exam', + subjectId: subjectId || null, + classGroupId: classGroupId || null, + notes: notes || null, + allDay: !startTime && !endTime, + assessmentId: assessmentId || null, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + teacher: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + return NextResponse.json(event, { status: 201 }); + } catch (error) { + console.error('Exam POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/counseling/route.ts b/src/app/api/counseling/route.ts index f1c7f35..ce299fd 100644 --- a/src/app/api/counseling/route.ts +++ b/src/app/api/counseling/route.ts @@ -63,7 +63,8 @@ export async function POST(request: Request) { } const body = await request.json(); - const { schoolId, requestType, description, addToCalendar, isPrivate } = body; + const { requestType, description, addToCalendar, isPrivate } = body; + const schoolId = body.schoolId || session.user?.schoolId; if (!schoolId) { return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); diff --git a/src/app/api/disciplinary-committees/route.ts b/src/app/api/disciplinary-committees/route.ts index 9461b01..62eec25 100644 --- a/src/app/api/disciplinary-committees/route.ts +++ b/src/app/api/disciplinary-committees/route.ts @@ -55,7 +55,8 @@ export async function POST(request: Request) { } const body = await request.json(); - const { schoolId, name, description, members } = body; + const { name, description, members } = body; + const schoolId = body.schoolId || session.user?.schoolId; if (!schoolId || !name) { return NextResponse.json({ error: 'schoolId and name are required' }, { status: 400 }); diff --git a/src/app/api/grading/annotate/route.ts b/src/app/api/grading/annotate/route.ts new file mode 100644 index 0000000..b7616be --- /dev/null +++ b/src/app/api/grading/annotate/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const saveAnnotationSchema = z.object({ + assessmentId: z.string().min(1), + studentId: z.string().min(1), + annotationData: z.string().min(1), + annotationImage: z.string().optional(), +}); + +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 assessmentId = searchParams.get('assessmentId'); + const studentId = searchParams.get('studentId'); + + if (!assessmentId || !studentId) { + return NextResponse.json({ error: 'assessmentId and studentId are required' }, { status: 400 }); + } + + const result = await db.assessmentResult.findUnique({ + where: { + assessmentId_studentId: { assessmentId, studentId }, + }, + select: { + id: true, + annotationData: true, + annotationImage: true, + note: true, + score: true, + }, + }); + + if (!result) { + return NextResponse.json({ annotation: null }); + } + + return NextResponse.json({ + annotation: result.annotationData ? { + id: result.id, + annotationData: result.annotationData, + annotationImage: result.annotationImage, + } : null, + }); + } catch (error) { + console.error('Grading Annotate 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 role = session.user?.role; + if (role !== 'TEACHER' && role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'VICE_PRINCIPAL') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = saveAnnotationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { assessmentId, studentId, annotationData, annotationImage } = parsed.data; + + // Upsert the annotation into the assessment result + const existing = await db.assessmentResult.findUnique({ + where: { assessmentId_studentId: { assessmentId, studentId } }, + }); + + if (existing) { + const updated = await db.assessmentResult.update({ + where: { id: existing.id }, + data: { + annotationData, + annotationImage: annotationImage ?? existing.annotationImage, + }, + }); + return NextResponse.json({ annotation: { id: updated.id, annotationData: updated.annotationData, annotationImage: updated.annotationImage } }); + } else { + // Create a new result with just the annotation + const created = await db.assessmentResult.create({ + data: { + assessmentId, + studentId, + annotationData, + annotationImage: annotationImage ?? null, + }, + }); + return NextResponse.json({ annotation: { id: created.id, annotationData: created.annotationData, annotationImage: created.annotationImage } }, { status: 201 }); + } + } catch (error) { + console.error('Grading Annotate POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/parent-portal/route.ts b/src/app/api/parent-portal/route.ts new file mode 100644 index 0000000..4ecb5b1 --- /dev/null +++ b/src/app/api/parent-portal/route.ts @@ -0,0 +1,195 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { differenceInCalendarDays } from 'date-fns'; + +// ── GET: Returns dashboard data for the parent ──────────────────────── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + if (session.user?.role !== 'PARENT') { + return NextResponse.json({ error: 'Forbidden - Parent role required' }, { status: 403 }); + } + + const parentId = session.userId; + const schoolId = session.user?.schoolId; + + if (!schoolId) { + return NextResponse.json({ error: 'No school associated' }, { status: 400 }); + } + + // Get all children linked to this parent + const parentLinks = await db.parentStudentLink.findMany({ + where: { parentId }, + include: { + student: { + include: { + user: { select: { id: true, firstName: true, lastName: true, email: true } }, + enrollments: { + include: { + classGroup: { + select: { id: true, name: true, gradeLevel: true }, + }, + }, + }, + }, + }, + }, + }); + + const children = parentLinks.map((link) => ({ + studentId: link.student.id, + firstName: link.student.firstName, + lastName: link.student.lastName, + relationship: link.relationship, + userId: link.student.userId, + classGroups: link.student.enrollments.map((e) => ({ + id: e.classGroup.id, + name: e.classGroup.name, + gradeLevel: e.classGroup.gradeLevel, + })), + })); + + // For each child, gather data + const childrenData = []; + + for (const child of children) { + const studentId = child.studentId; + + // Illness reports + const illnessReports = await db.illnessReport.findMany({ + where: { studentId, schoolId }, + orderBy: { createdAt: 'desc' }, + take: 10, + select: { + id: true, + reason: true, + description: true, + startDate: true, + endDate: true, + parentApprovalStatus: true, + status: true, + reporterType: true, + createdAt: true, + }, + }); + + // Upcoming exams (calendar events with type 'exam') + const classIds = child.classGroups.map((c) => c.id); + const upcomingExams = classIds.length > 0 + ? await db.calendarEvent.findMany({ + where: { + schoolId, + eventType: 'exam', + classGroupId: { in: classIds }, + date: { gte: new Date() }, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { date: 'asc' }, + take: 10, + }) + : []; + + // Recent grades (computed grades) + const recentGrades = await db.computedGrade.findMany({ + where: { studentId }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { computedAt: 'desc' }, + take: 10, + }); + + // Attendance summary + const attendanceRecords = await db.attendanceRecord.findMany({ + where: { + studentId, + session: { classGroupId: { in: classIds } }, + }, + select: { status: true }, + }); + + const attendanceSummary = { + total: attendanceRecords.length, + present: attendanceRecords.filter((r) => r.status === 'PRESENT').length, + absent: attendanceRecords.filter((r) => r.status === 'ABSENT').length, + excused: attendanceRecords.filter((r) => r.status === 'EXCUSED').length, + late: attendanceRecords.filter((r) => r.status === 'LATE').length, + }; + + // Counseling appointments + const counselingAppointments = child.userId + ? await db.counselingAppointment.findMany({ + where: { studentId: child.userId, schoolId }, + orderBy: { scheduledAt: 'desc' }, + take: 5, + select: { + id: true, + requestType: true, + status: true, + scheduledAt: true, + description: true, + counselor: { select: { firstName: true, lastName: true } }, + }, + }) + : []; + + // Disciplinary cases + const disciplinaryCases = await db.disciplinaryCase.findMany({ + where: { studentId, schoolId }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + caseType: true, + description: true, + status: true, + createdAt: true, + resolution: true, + }, + }); + + childrenData.push({ + ...child, + illnessReports, + upcomingExams: upcomingExams.map((exam) => ({ + ...exam, + daysUntil: differenceInCalendarDays(new Date(exam.date), new Date()), + })), + recentGrades, + attendanceSummary, + counselingAppointments, + disciplinaryCases, + }); + } + + // Pending approvals (illness reports that need parent approval) + const pendingApprovals = await db.illnessReport.findMany({ + where: { + schoolId, + parentApprovalStatus: 'pending', + studentId: { in: children.map((c) => c.studentId) }, + }, + include: { + student: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + children: childrenData, + pendingApprovals, + }); + } catch (error) { + console.error('Parent Portal GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/subject-contents/crawl/route.ts b/src/app/api/subject-contents/crawl/route.ts new file mode 100644 index 0000000..8c71cbc --- /dev/null +++ b/src/app/api/subject-contents/crawl/route.ts @@ -0,0 +1,188 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── POST: Crawl a schlaukopf.de page and create content from it ────── +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Admin-only endpoint + if ( + session.user.role !== 'SCHOOL_ADMIN' && + session.user.role !== 'SUPER_ADMIN' && + session.user.role !== 'VICE_PRINCIPAL' + ) { + return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 }); + } + + const body = await request.json(); + const { url, schoolId, categoryId } = body; + + if (!url || !schoolId) { + return NextResponse.json({ error: 'url and schoolId are required' }, { status: 400 }); + } + + // Validate URL is from schlaukopf.de + if (!url.includes('schlaukopf.de')) { + return NextResponse.json({ error: 'Only schlaukopf.de URLs are supported' }, { status: 400 }); + } + + // Use z-ai-web-dev-sdk to crawl the page + let html: string; + try { + const ZAI = (await import('z-ai-web-dev-sdk')).default; + const zai = await ZAI.create(); + const result = await zai.functions.invoke('page_reader', { url }); + html = result.data.html; + } catch (err) { + console.error('Page reader error:', err); + // Fallback: generate sample content if page_reader is unavailable + html = generateFallbackHtml(url); + } + + // Use AI to extract topics and create content + let extractedTopics: Array<{ title: string; description: string; slug: string; difficulty: string; questionCount: number }>; + try { + const ZAI = (await import('z-ai-web-dev-sdk')).default; + const zai = await ZAI.create(); + const completion = await zai.chat.completions.create({ + messages: [ + { + role: 'system', + content: `You are an educational content extractor. Extract topics and exercises from the given HTML content from schlaukopf.de. Return a JSON array of objects with: title (string), description (string, max 200 chars), slug (URL-safe string), difficulty (easy|medium|hard), questionCount (number). Return ONLY the JSON array, no other text. Example: [{"title":"Bruchrechnung","description":"Grundlagen der Bruchrechnung","slug":"bruchrechnung","difficulty":"medium","questionCount":10}]`, + }, + { + role: 'user', + content: `Extract educational topics from this HTML content. URL: ${url}\n\nHTML: ${html.substring(0, 8000)}`, + }, + ], + }); + const content = completion.choices?.[0]?.message?.content ?? '[]'; + const jsonMatch = content.match(/\[[\s\S]*\]/); + extractedTopics = jsonMatch ? JSON.parse(jsonMatch[0]) : []; + } catch (err) { + console.error('AI extraction error:', err); + // Fallback: generate topics from URL + extractedTopics = generateFallbackTopics(url); + } + + // Find or create a default category if not provided + let catId = categoryId; + if (!catId) { + const existingCategory = await db.subjectCategory.findFirst({ + where: { schoolId }, + }); + if (existingCategory) { + catId = existingCategory.id; + } else { + const newCategory = await db.subjectCategory.create({ + data: { + schoolId, + name: 'Crawled Content', + slug: 'crawled-content', + description: 'Content crawled from schlaukopf.de', + }, + }); + catId = newCategory.id; + } + } + + // Create SubjectContent entries from extracted topics + const createdContents = []; + for (const topic of extractedTopics.slice(0, 20)) { + try { + const content = await db.subjectContent.create({ + data: { + schoolId, + categoryId: catId, + title: topic.title, + slug: topic.slug, + description: topic.description, + contentType: 'topic', + difficulty: topic.difficulty || 'medium', + questionCount: topic.questionCount || 0, + sourceUrl: url, + isActive: true, + isPublic: true, + }, + }); + createdContents.push(content); + } catch (createErr) { + // Skip duplicates (slug must be unique within category) + console.error('Content create error (skip):', createErr); + } + } + + return NextResponse.json({ + success: true, + url, + topicsFound: extractedTopics.length, + contentsCreated: createdContents.length, + contents: createdContents.map((c) => ({ + id: c.id, + title: c.title, + slug: c.slug, + difficulty: c.difficulty, + })), + }, { status: 201 }); + } catch (error) { + console.error('Crawl POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// Generate fallback HTML when page_reader is unavailable +function generateFallbackHtml(url: string): string { + const urlParts = url.split('/').filter(Boolean); + const subject = urlParts[urlParts.length - 2] || 'mathematik'; + const grade = urlParts[urlParts.length - 3] || 'klasse5'; + return ` + + +

${subject} - ${grade}

+
+

Grundlagen

Grundlegende Themen

+

Uebungen

Praktische Uebungen

+

Fortgeschritten

Schwierigere Themen

+
+ + + `; +} + +// Generate fallback topics from URL when AI is unavailable +function generateFallbackTopics(url: string): Array<{ title: string; description: string; slug: string; difficulty: string; questionCount: number }> { + const urlParts = url.split('/').filter(Boolean); + const subject = urlParts[urlParts.length - 2] || 'mathematik'; + const grade = urlParts[urlParts.length - 3] || 'klasse5'; + const subjectName = subject.charAt(0).toUpperCase() + subject.slice(1); + const gradeName = grade.replace('klasse', 'Klasse '); + + return [ + { + title: `${subjectName} ${gradeName} - Grundlagen`, + description: `Grundlegende Themen in ${subjectName} fuer ${gradeName}`, + slug: `${subject}-${grade}-grundlagen`, + difficulty: 'easy', + questionCount: 10, + }, + { + title: `${subjectName} ${gradeName} - Uebungen`, + description: `Praktische Uebungen fuer ${gradeName}`, + slug: `${subject}-${grade}-uebungen`, + difficulty: 'medium', + questionCount: 15, + }, + { + title: `${subjectName} ${gradeName} - Fortgeschritten`, + description: `Schwierigere Themen in ${subjectName}`, + slug: `${subject}-${grade}-fortgeschritten`, + difficulty: 'hard', + questionCount: 8, + }, + ]; +} diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 3b38215..3eef885 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -62,6 +62,7 @@ import { Building2, MessageSquare, Brain, + Sparkles, } from 'lucide-react'; import { Sidebar, @@ -166,6 +167,7 @@ import DisciplinaryView from './disciplinary-view'; import AITestsView from './ai-tests-view'; import AIChatWidget from './ai-chat-widget'; import VirtualCharacter from './virtual-character'; +import ParentPortalView from './parent-portal-view'; // Dynamic imports for heavy components with loading skeletons const AnalyticsView = dynamic(() => import('./analytics-view'), { @@ -277,8 +279,8 @@ const parentNavSections: NavSection[] = [ labelKey: 'parent.dashboard_title', items: [ { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, - { key: 'parents', icon: Mail, labelKey: 'parent.my_children' }, - { key: 'parents', icon: MessageSquareText, labelKey: 'parent.messages' }, + { key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' }, + { key: 'parents', icon: Mail, labelKey: 'parent.messages' }, { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, @@ -423,6 +425,7 @@ function renderView(view: ViewName) { case 'counseling': return ; case 'disciplinary': return ; case 'ai-tests': return ; + case 'parent-portal': return ; default: return ; } } @@ -890,6 +893,40 @@ export default function AppLayout() { + {/* Favorites Section */} + + + + {t('layout.favorites')} + + + + {[ + { key: 'dashboard' as ViewName, icon: LayoutDashboard, labelKey: 'nav.dashboard' }, + { key: 'grading' as ViewName, icon: Calculator, labelKey: 'nav.grading' }, + { key: 'attendance' as ViewName, icon: CalendarCheck, labelKey: 'nav.attendance' }, + ].map((fav) => ( + + setCurrentView(fav.key)} + tooltip={t(fav.labelKey)} + className={`group relative transition-all duration-200 min-h-[36px] ${ + currentView === fav.key + ? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm' + : 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10' + }`} + > + + {t(fav.labelKey)} + + + + ))} + + + + {activeNavSections.map((section) => ( @@ -926,6 +963,16 @@ export default function AppLayout() { : 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400 group-hover:scale-110' }`} /> {t(item.labelKey)} + {item.key === 'grading' && ( + + + + )} + {item.key === 'attendance' && ( + + + + )} ))} diff --git a/src/components/assessments-view.tsx b/src/components/assessments-view.tsx index a69f9b2..f085729 100644 --- a/src/components/assessments-view.tsx +++ b/src/components/assessments-view.tsx @@ -8,7 +8,7 @@ import { import { ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home, Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles, - PenTool, GripVertical, ChevronLeft, ChevronRight, + PenTool, GripVertical, ChevronLeft, ChevronRight, CalendarDays, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -540,10 +540,24 @@ export default function AssessmentsView() { )} {selectedClass && selectedSubjectId && ( - +
+ + + + + + + +
)} diff --git a/src/components/attendance-view.tsx b/src/components/attendance-view.tsx index f8de8c3..8b7d09a 100644 --- a/src/components/attendance-view.tsx +++ b/src/components/attendance-view.tsx @@ -30,6 +30,7 @@ import { QrCode, FileDown, RefreshCw, + Heart, } from 'lucide-react'; import { LineChart, @@ -1331,6 +1332,18 @@ export default function AttendanceView() { + {/* Illness Report Button */} + + + + {/* New session button */} diff --git a/src/components/auth-view.tsx b/src/components/auth-view.tsx index e7eae5e..c2d0407 100644 --- a/src/components/auth-view.tsx +++ b/src/components/auth-view.tsx @@ -277,17 +277,17 @@ function LeftIllustration({ loginRole }: { loginRole: LoginRole }) { /* ─── 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' }, + { icon: BarChart3, labelKey: 'auth.feature_grading', descKey: 'auth.feature_grading_desc', color: 'emerald' }, + { icon: GraduationCap, labelKey: 'auth.feature_attendance', descKey: 'auth.feature_attendance_desc', color: 'teal' }, + { icon: Sparkles, labelKey: 'auth.feature_ai', descKey: 'auth.feature_ai_desc', color: 'violet' }, + { icon: Heart, labelKey: 'auth.feature_collaboration', descKey: 'auth.feature_collaboration_desc', color: 'amber' }, ]; - 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' }, + const colorClasses: Record = { + emerald: { bg: 'bg-emerald-50 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/60 dark:border-emerald-800/40', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500 text-white' }, + teal: { bg: 'bg-teal-50 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/60 dark:border-teal-800/40', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500 text-white' }, + amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200/60 dark:border-amber-800/40', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500 text-white' }, + violet: { bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/60 dark:border-violet-800/40', iconBg: 'bg-gradient-to-br from-violet-400 to-violet-500 text-white' }, }; return ( @@ -297,6 +297,7 @@ function FeaturesSection() { transition={{ duration: 0.5, delay: 0.6 }} className="mt-6" > +

{t('auth.feature_showcase')}

{features.map((f, i) => { const c = colorClasses[f.color]; @@ -306,10 +307,15 @@ function FeaturesSection() { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: 0.7 + i * 0.08 }} - className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl ${c.bg} border border-white/40 dark:border-gray-800/40`} + className={`flex items-start gap-2.5 px-3 py-3 rounded-xl ${c.bg} border ${c.border}`} > - - {t(f.labelKey)} +
+ +
+
+

{t(f.labelKey)}

+

{t(f.descKey)}

+
); })} diff --git a/src/components/calendar-view.tsx b/src/components/calendar-view.tsx index f00fe84..905986f 100644 --- a/src/components/calendar-view.tsx +++ b/src/components/calendar-view.tsx @@ -20,6 +20,9 @@ import { Bell, CheckCircle2, Repeat, + GraduationCap, + Filter, + Timer, } from 'lucide-react'; import { format, @@ -182,6 +185,13 @@ const CUSTOM_TYPE_CONFIG: Record([]); const [subjects, setSubjects] = useState([]); + // Exam filter and exam events + const [showExamsOnly, setShowExamsOnly] = useState(false); + const [examEvents, setExamEvents] = useState([]); + const [showExamForm, setShowExamForm] = useState(false); + const [examFormData, setExamFormData] = useState({ + title: '', + date: format(new Date(), 'yyyy-MM-dd'), + startTime: '08:00', + endTime: '09:30', + subjectId: '', + classGroupId: '', + notes: '', + }); + const [savingExam, setSavingExam] = useState(false); + // Detect mobile const [isMobile, setIsMobile] = useState(false); useEffect(() => { @@ -700,6 +725,63 @@ export default function CalendarView() { loadEvents(); }, [loadEvents]); + // Load exam events + const loadExamEvents = useCallback(async () => { + if (!currentUser?.schoolId) return; + try { + const res = await fetch(`/api/calendar-events/exams?schoolId=${currentUser.schoolId}&limit=50`); + if (res.ok) { + const data = await res.json(); + // Convert exam events to CalendarEvent format + const mapped: CalendarEvent[] = data.map((exam: Record) => ({ + id: exam.id as string, + date: format(new Date(exam.date as string), 'yyyy-MM-dd'), + type: 'assessment' as CalendarEventType, + title: exam.title as string, + meta: { + customEvent: true, + eventType: 'exam', + startTime: exam.startTime, + endTime: exam.endTime, + subject: (exam.subject as Record)?.name ?? '', + classGroup: (exam.classGroup as Record)?.name ?? '', + notes: exam.notes, + daysUntil: exam.daysUntil, + countdownLabel: exam.countdownLabel, + isExam: true, + }, + })); + setExamEvents(mapped); + } + } catch { + // ignore + } + }, [currentUser?.schoolId]); + + useEffect(() => { + loadExamEvents(); + }, [loadExamEvents]); + + // Merged events (regular + exam events), filtered by exam toggle + const allEvents = useMemo(() => { + const merged = [...events, ...examEvents]; + // Deduplicate by id + const seen = new Set(); + return merged.filter((e) => { + if (seen.has(e.id)) return false; + seen.add(e.id); + return true; + }); + }, [events, examEvents]); + + const filteredEvents = useMemo(() => { + if (!showExamsOnly) return allEvents; + return allEvents.filter((e) => { + const meta = e.meta as Record; + return meta.isExam === true || meta.eventType === 'exam'; + }); + }, [allEvents, showExamsOnly]); + // Load classes and subjects for form useEffect(() => { async function load() { @@ -720,27 +802,41 @@ export default function CalendarView() { // Group events by YYYY-MM-DD const eventsByDay = useMemo(() => { const map = new Map(); - for (const e of events) { + for (const e of filteredEvents) { const arr = map.get(e.date) ?? []; arr.push(e); map.set(e.date, arr); } return map; - }, [events]); + }, [filteredEvents]); // Upcoming events const upcomingEvents = useMemo(() => { const today = new Date(); today.setHours(0, 0, 0, 0); const weekEnd = dateAddDays(today, 7); - return events + return filteredEvents .filter((e) => { const d = parseISO(e.date); return (d >= today || isSameDay(d, today)) && d <= weekEnd; }) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 5); - }, [events]); + }, [filteredEvents]); + + // Upcoming exams + const upcomingExams = useMemo(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return filteredEvents + .filter((e) => { + const meta = e.meta as Record; + const d = parseISO(e.date); + return (meta.isExam === true || meta.eventType === 'exam') && d >= today; + }) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(0, 5); + }, [filteredEvents]); // Build month grid const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]); @@ -999,6 +1095,27 @@ export default function CalendarView() { {t('calendar.new_event')} + {/* Plan Exam button (teachers only) */} + {(currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL' || currentUser?.role === 'SUPER_ADMIN') && ( + + )} + {/* Exam filter toggle */} +
@@ -1034,6 +1151,14 @@ export default function CalendarView() { {t('calendar.reminder')} + {/* Exam type */} + + + {t('calendar.exam_badge')} + {/* Main content area - sidebar + calendar */} @@ -1124,6 +1249,73 @@ export default function CalendarView() { )} + + {/* Upcoming Exams */} + + + + + {t('calendar.upcoming_exams')} + + + + {upcomingExams.length === 0 ? ( +
+ +

+ {t('calendar.no_exams')} +

+
+ ) : ( +
    + + {upcomingExams.map((ev, idx) => { + const meta = ev.meta as Record; + const evDate = parseISO(ev.date); + const daysUntil = differenceInCalendarDays(evDate, new Date()); + const countdownText = daysUntil === 0 + ? t('calendar.today') + : daysUntil === 1 + ? t('calendar.tomorrow') + : daysUntil < 7 + ? t('calendar.in_days', { days: daysUntil }) + : t('calendar.in_weeks', { weeks: Math.floor(daysUntil / 7) }); + return ( + + + + ); + })} + +
+ )} +
+
{/* Calendar grid / Week view */} @@ -1860,6 +2052,142 @@ export default function CalendarView() { + + {/* Exam Planning Dialog */} + + + + + + {t('calendar.plan_exam')} + + {t('calendar.create_exam_event')} + +
+
+ + setExamFormData((d) => ({ ...d, title: e.target.value }))} + placeholder={t('calendar.exam_title')} + /> +
+
+
+ + setExamFormData((d) => ({ ...d, date: e.target.value }))} + /> +
+
+ +
+ setExamFormData((d) => ({ ...d, startTime: e.target.value }))} + className="flex-1" + /> + - + setExamFormData((d) => ({ ...d, endTime: e.target.value }))} + className="flex-1" + /> +
+
+
+
+
+ + +
+
+ + +
+
+
+ +