diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 613abbc..ffa771a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,8 @@ model School { commentBankEntries CommentBankEntry[] notebooks Notebook[] drawings Drawing[] + notifications Notification[] + calendarEvents CalendarEvent[] } model User { @@ -71,6 +73,8 @@ model User { commentBankEntries CommentBankEntry[] notebooks Notebook[] drawings Drawing[] + notifications Notification[] + calendarEvents CalendarEvent[] } model SchoolYear { @@ -115,6 +119,7 @@ model ClassGroup { behaviorIncidents BehaviorIncident[] notebooks Notebook[] drawings Drawing[] + calendarEvents CalendarEvent[] } model ClassGroupTeacher { @@ -187,6 +192,7 @@ model Subject { commentBankEntries CommentBankEntry[] notebooks Notebook[] drawings Drawing[] + calendarEvents CalendarEvent[] } model CompetencyTemplate { @@ -800,3 +806,55 @@ model Drawing { @@index([ownerId]) @@index([subjectId]) } + +model Notification { + id String @id @default(cuid()) + schoolId String + userId String // recipient + type String // ASSESSMENT_DUE, MISSING_OBSERVATION, NOTEBOOK_SHARED, BEHAVIOR_ALERT, GRADE_COMPUTED, etc. + title String + message String + isRead Boolean @default(false) + actionUrl String? // optional deep link + relatedId String? // optional related entity ID + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([userId, isRead]) + @@index([schoolId]) + @@map("notifications") +} + +// ─── Calendar Events ────────────────────────────────────────────── + +model CalendarEvent { + id String @id @default(cuid()) + schoolId String + teacherId String + title String + 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 + subjectId String? + classGroupId String? + notes String? + allDay Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + teacher User @relation(fields: [teacherId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + + @@index([schoolId]) + @@index([teacherId]) + @@index([date]) + @@index([subjectId]) + @@index([classGroupId]) +} diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index c91e75e..712bfce 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -259,7 +259,7 @@ export async function GET(request: Request) { let enrollments: Array<{ studentId: string; classGroupId: string; - student: { id: string; firstName: string; lastName: string; deletedAt: string | null } | null; + student: { id: string; firstName: string; lastName: string; deletedAt: Date | null } | null; classGroup: { id: string; name: string } | null; }> = []; try { diff --git a/src/app/api/calendar-events/[id]/route.ts b/src/app/api/calendar-events/[id]/route.ts new file mode 100644 index 0000000..f51f6fc --- /dev/null +++ b/src/app/api/calendar-events/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +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, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay } = body; + + const existing = await db.calendarEvent.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + const event = await db.calendarEvent.update({ + where: { id }, + data: { + title: title ?? existing.title, + date: date ? new Date(date) : existing.date, + startTime: startTime !== undefined ? startTime : existing.startTime, + endTime: endTime !== undefined ? endTime : existing.endTime, + eventType: eventType ?? existing.eventType, + subjectId: subjectId !== undefined ? subjectId : existing.subjectId, + classGroupId: classGroupId !== undefined ? classGroupId : existing.classGroupId, + notes: notes !== undefined ? notes : existing.notes, + allDay: allDay !== undefined ? allDay : existing.allDay, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(event); + } catch (error) { + console.error('CalendarEvent 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 existing = await db.calendarEvent.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Event not found' }, { status: 404 }); + } + + await db.calendarEvent.delete({ where: { id } }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('CalendarEvent DELETE error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/calendar-events/route.ts b/src/app/api/calendar-events/route.ts new file mode 100644 index 0000000..85d38ed --- /dev/null +++ b/src/app/api/calendar-events/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +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 { title, date, startTime, endTime, eventType, subjectId, classGroupId, notes, allDay, 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: eventType || 'reminder', + subjectId: subjectId || null, + classGroupId: classGroupId || null, + notes: notes || null, + allDay: allDay ?? false, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(event, { status: 201 }); + } catch (error) { + console.error('CalendarEvent POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +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 monthParam = searchParams.get('month'); // YYYY-MM + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + let year: number; + let monthIndex: number; + if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) { + const [yStr, mStr] = monthParam.split('-'); + year = parseInt(yStr, 10); + monthIndex = parseInt(mStr, 10) - 1; + } else { + const now = new Date(); + year = now.getUTCFullYear(); + monthIndex = now.getUTCMonth(); + } + + const startOfMonth = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0)); + const endOfMonth = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999)); + + const events = await db.calendarEvent.findMany({ + where: { + schoolId, + date: { gte: startOfMonth, lte: endOfMonth }, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { date: 'asc' }, + }); + + return NextResponse.json(events); + } catch (error) { + console.error('CalendarEvent GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index 085d79b..3b8d8b8 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -294,6 +294,54 @@ export async function GET(request: Request) { console.info('Calendar: LessonPlan not available yet, skipping lessons.'); } + /* ── Custom Calendar Events ───────────────────────────────────── */ + try { + const customEvents = await db.calendarEvent.findMany({ + where: { + schoolId, + date: dateRange, + }, + select: { + id: true, + title: true, + date: true, + startTime: true, + endTime: true, + eventType: true, + allDay: true, + notes: true, + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + }, + orderBy: { date: 'asc' }, + }); + + for (const ce of customEvents) { + // Map custom event types to CalendarEventType + const mappedType: EventType = ['assessment', 'lesson'].includes(ce.eventType) + ? (ce.eventType as EventType) + : 'lesson'; + events.push({ + date: toDayKey(ce.date), + type: mappedType, + id: ce.id, + title: ce.title, + meta: { + customEvent: true, + eventType: ce.eventType, + startTime: ce.startTime ?? null, + endTime: ce.endTime ?? null, + allDay: ce.allDay, + notes: ce.notes ?? null, + subject: ce.subject?.name ?? null, + classGroup: ce.classGroup?.name ?? null, + }, + }); + } + } catch (err) { + console.error('Calendar: custom events query failed:', err); + } + // Stable ordering: by date asc, then by type for deterministic display const typeOrder: Record = { lesson: 0, diff --git a/src/app/api/notebooks/[id]/pages/reorder/route.ts b/src/app/api/notebooks/[id]/pages/reorder/route.ts new file mode 100644 index 0000000..af65be2 --- /dev/null +++ b/src/app/api/notebooks/[id]/pages/reorder/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── PUT: Reorder pages ── +const reorderSchema = z.object({ + pageOrders: z.array( + z.object({ + id: z.string(), + pageNumber: z.number().int(), + }) + ), +}); + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + + const notebook = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!notebook) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + if (notebook.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = reorderSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // Update each page's pageNumber in a transaction + await db.$transaction( + parsed.data.pageOrders.map((po) => + db.notebookPage.update({ + where: { id: po.id }, + data: { pageNumber: po.pageNumber }, + }) + ) + ); + + // Return updated pages + const pages = await db.notebookPage.findMany({ + where: { notebookId: id }, + orderBy: { pageNumber: 'asc' }, + }); + + return NextResponse.json(pages); + } catch (error) { + console.error('Notebook page reorder error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 6790478..b214273 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,11 +1,8 @@ -// CompetenceTrack — Notifications API +// CompetenceTrack — Notifications API (DB-backed) import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; -// In-memory store for read notification IDs (per server instance) -const readNotificationIds = new Set(); - export async function GET() { const session = await getSession(); if (!session?.user) { @@ -14,19 +11,31 @@ export async function GET() { const userId = session.user.id; const schoolId = session.user.schoolId; - const role = session.user.role; if (!schoolId) { return NextResponse.json({ - upcomingAssessments: [], - missingObservations: [], + notifications: [], unreadCount: 0, }); } + // Fetch DB notifications for the current user + const notifications = await db.notification.findMany({ + where: { + userId, + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + + const unreadCount = notifications.filter((n) => !n.isRead).length; + + // Also generate dynamic notifications from existing data const now = new Date(); const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const role = session.user.role; // Get teacher's class IDs const teacherClasses = await db.classGroupTeacher.findMany({ @@ -45,105 +54,138 @@ export async function GET() { targetClassIds = schoolClasses.map((c) => c.id); } - if (targetClassIds.length === 0) { - return NextResponse.json({ - upcomingAssessments: [], - missingObservations: [], - unreadCount: 0, - }); - } - - // Upcoming assessments (next 7 days, not completed) - const upcomingAssessments = await db.assessment.findMany({ - where: { - classGroupId: { in: targetClassIds }, - date: { - gte: now, - lte: sevenDaysFromNow, - }, - }, - include: { - classGroup: { select: { name: true } }, - subject: { select: { name: true } }, - assessmentResults: { select: { id: true } }, - }, - orderBy: { date: 'asc' }, - take: 10, - }); - - const assessmentNotifications = upcomingAssessments - .filter((a) => a.assessmentResults.length === 0) - .map((a) => { - const diffMs = new Date(a.date).getTime() - now.getTime(); - const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24))); - const id = `assessment-${a.id}`; - return { - id, - type: 'assessment' as const, - title: a.title, - description: `${a.classGroup.name} · ${a.subject.name}`, - daysUntil, - timestamp: t('notifications.in_days', { days: String(daysUntil) }), - classGroupId: a.classGroupId, - subjectId: a.subjectId, - assessmentId: a.id, - isRead: readNotificationIds.has(id), - }; - }); - - // Students with no progress entries in last 14 days - const activeEnrollments = await db.enrollment.findMany({ - where: { - classGroupId: { in: targetClassIds }, - endDate: null, - }, - include: { - student: { - select: { - id: true, - firstName: true, - lastName: true, - learningProgressEntries: { - where: { - date: { gte: fourteenDaysAgo }, - }, - select: { id: true }, - take: 1, - }, + // For students, get their enrolled class IDs + if (role === 'STUDENT') { + const student = await db.student.findFirst({ + where: { schoolId }, + include: { + enrollments: { + where: { endDate: null }, + select: { classGroupId: true }, }, }, - classGroup: { select: { id: true, name: true } }, - }, - }); + }); + if (student) { + targetClassIds = student.enrollments.map((e) => e.classGroupId); + } + } - const missingObservations = activeEnrollments - .filter((e) => e.student.learningProgressEntries.length === 0) - .map((e) => { - const id = `missing-${e.student.id}-${e.classGroupId}`; - // Calculate days since last progress (use 14 as default since we filtered for 14 days) - return { - id, - type: 'missing_observation' as const, - title: `${e.student.firstName} ${e.student.lastName}`, - description: e.classGroup.name, - daysSince: 14, - timestamp: t('notifications.days_ago', { days: '14' }), - studentId: e.student.id, - classGroupId: e.classGroupId, - isRead: readNotificationIds.has(id), - }; + // Upcoming assessments (next 7 days) + const upcomingAssessments: Array<{ + id: string; + type: string; + title: string; + message: string; + isRead: boolean; + actionUrl: string | null; + relatedId: string | null; + createdAt: string; + }> = []; + + if (targetClassIds.length > 0) { + const assessments = await db.assessment.findMany({ + where: { + classGroupId: { in: targetClassIds }, + date: { + gte: now, + lte: sevenDaysFromNow, + }, + }, + include: { + classGroup: { select: { name: true } }, + subject: { select: { name: true } }, + assessmentResults: { select: { id: true } }, + }, + orderBy: { date: 'asc' }, + take: 10, }); - // Limit to 10 most relevant - const limitedMissing = missingObservations.slice(0, 10); + for (const a of assessments) { + const diffMs = new Date(a.date).getTime() - now.getTime(); + const daysUntil = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24))); + // Check if we already have a DB notification for this assessment + const existing = notifications.find( + (n) => n.type === 'ASSESSMENT_DUE' && n.relatedId === a.id + ); + if (!existing) { + upcomingAssessments.push({ + id: `assessment-${a.id}`, + type: 'ASSESSMENT_DUE', + title: a.title, + message: `${a.classGroup.name} - ${a.subject.name}`, + isRead: false, + actionUrl: 'assessments', + relatedId: a.id, + createdAt: new Date().toISOString(), + }); + } + } - const allItems = [...assessmentNotifications, ...limitedMissing]; - const unreadCount = allItems.filter((n) => !n.isRead).length; + // Missing observations (students with no progress in 14 days) - only for teachers + if (role !== 'STUDENT') { + const activeEnrollments = await db.enrollment.findMany({ + where: { + classGroupId: { in: targetClassIds }, + endDate: null, + }, + include: { + student: { + select: { + id: true, + firstName: true, + lastName: true, + learningProgressEntries: { + where: { + date: { gte: fourteenDaysAgo }, + }, + select: { id: true }, + take: 1, + }, + }, + }, + classGroup: { select: { id: true, name: true } }, + }, + }); + + for (const e of activeEnrollments) { + if (e.student.learningProgressEntries.length === 0) { + const existing = notifications.find( + (n) => n.type === 'MISSING_OBSERVATION' && n.relatedId === e.student.id + ); + if (!existing) { + upcomingAssessments.push({ + id: `missing-${e.student.id}-${e.classGroupId}`, + type: 'MISSING_OBSERVATION', + title: `${e.student.firstName} ${e.student.lastName}`, + message: e.classGroup.name, + isRead: false, + actionUrl: 'progress', + relatedId: e.student.id, + createdAt: new Date().toISOString(), + }); + } + } + } + } + } + + // Combine DB notifications with dynamic ones + const dbNotifications = notifications.map((n) => ({ + id: n.id, + type: n.type, + title: n.title, + message: n.message, + isRead: n.isRead, + actionUrl: n.actionUrl, + relatedId: n.relatedId, + createdAt: n.createdAt.toISOString(), + })); + + const allNotifications = [...dbNotifications, ...upcomingAssessments]; return NextResponse.json({ - upcomingAssessments: assessmentNotifications, - missingObservations: limitedMissing, - unreadCount, + notifications: allNotifications, + unreadCount: allNotifications.filter((n) => !n.isRead).length, }); } @@ -155,12 +197,110 @@ export async function PUT(request: Request) { try { const body = await request.json(); + const { ids, markAll } = body as { ids?: string[]; markAll?: boolean }; + + if (markAll) { + // Mark all notifications as read + await db.notification.updateMany({ + where: { + userId: session.user.id, + isRead: false, + deletedAt: null, + }, + data: { isRead: true }, + }); + return NextResponse.json({ success: true }); + } + + if (ids && Array.isArray(ids)) { + // Mark specific DB notifications as read + const dbIds = ids.filter((id) => !id.startsWith('assessment-') && !id.startsWith('missing-')); + if (dbIds.length > 0) { + await db.notification.updateMany({ + where: { + id: { in: dbIds }, + userId: session.user.id, + }, + data: { isRead: true }, + }); + } + return NextResponse.json({ success: true }); + } + + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} + +export async function POST(request: Request) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { schoolId, userId, type, title, message, actionUrl, relatedId } = body as { + schoolId: string; + userId: string; + type: string; + title: string; + message: string; + actionUrl?: string; + relatedId?: string; + }; + + if (!schoolId || !userId || !type || !title || !message) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const notification = await db.notification.create({ + data: { + schoolId, + userId, + type, + title, + message, + actionUrl: actionUrl ?? null, + relatedId: relatedId ?? null, + }, + }); + + return NextResponse.json({ notification }, { status: 201 }); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} + +export async function DELETE(request: Request) { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json().catch(() => ({})); const { ids } = body as { ids?: string[] }; if (ids && Array.isArray(ids)) { - for (const id of ids) { - readNotificationIds.add(id); - } + // Soft-delete specific notifications + await db.notification.updateMany({ + where: { + id: { in: ids }, + userId: session.user.id, + }, + data: { deletedAt: new Date() }, + }); + } else { + // Soft-delete all notifications for the user + await db.notification.updateMany({ + where: { + userId: session.user.id, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }); } return NextResponse.json({ success: true }); @@ -168,18 +308,3 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); } } - -// Helper — minimal i18n for server-side timestamps -function t(key: string, params?: Record): string { - const dict: Record = { - 'notifications.in_days': 'in {days} Tagen', - 'notifications.days_ago': 'vor {days} Tagen', - }; - let text = dict[key] ?? key; - if (params) { - for (const [k, v] of Object.entries(params)) { - text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); - } - } - return text; -} diff --git a/src/app/globals.css b/src/app/globals.css index 53e396a..6951c56 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -675,6 +675,125 @@ .animate-chart-shimmer { animation: chart-shimmer 1.5s ease-out forwards; } + + /* ─── Button Press Effect ────────────────────────────────────────── */ + .btn-press { + transition: transform 0.1s ease-out, box-shadow 0.1s ease-out; + } + .btn-press:active { + transform: scale(0.97); + } + .btn-press:focus-visible { + outline: 2px solid oklch(0.55 0.17 155); + outline-offset: 2px; + box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.15); + } + .dark .btn-press:focus-visible { + outline: 2px solid oklch(0.65 0.17 155); + box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.2); + } + + /* ─── Smooth Theme Switching ─────────────────────────────────────── */ + html { + transition: background-color 0.3s ease, color 0.3s ease; + } + html *, + html *::before, + html *::after { + transition-property: background-color, border-color, color, fill, stroke, box-shadow; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + @media (prefers-reduced-motion: reduce) { + html *, + html *::before, + html *::after { + transition-duration: 0ms !important; + } + } + + /* ─── Skeleton Loading Shimmer ───────────────────────────────────── */ + .skeleton-shimmer { + position: relative; + overflow: hidden; + background: oklch(0.92 0.01 155 / 0.5); + } + .dark .skeleton-shimmer { + background: oklch(0.25 0.01 260 / 0.5); + } + .skeleton-shimmer::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + oklch(0.97 0.01 155 / 0.4) 50%, + transparent 100% + ); + animation: skeleton-slide 1.5s ease-in-out infinite; + } + .dark .skeleton-shimmer::after { + background: linear-gradient( + 90deg, + transparent 0%, + oklch(0.35 0.01 260 / 0.3) 50%, + transparent 100% + ); + } + @keyframes skeleton-slide { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } + } + + /* ─── Interactive Card Hover Micro-Effect ─────────────────────────── */ + .card-interactive { + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; + } + .card-interactive:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.12); + } + .dark .card-interactive:hover { + box-shadow: 0 8px 24px -6px oklch(0.35 0.17 155 / 0.2); + } + .card-interactive:active { + transform: translateY(0px) scale(0.99); + } + + /* ─── Danger Zone Styling ────────────────────────────────────────── */ + .danger-zone { + border: 2px dashed oklch(0.55 0.22 25 / 0.4); + border-radius: 12px; + padding: 1.5rem; + background: oklch(0.55 0.22 25 / 0.03); + } + .dark .danger-zone { + border-color: oklch(0.55 0.22 25 / 0.3); + background: oklch(0.55 0.22 25 / 0.05); + } + .danger-zone:hover { + border-color: oklch(0.55 0.22 25 / 0.6); + background: oklch(0.55 0.22 25 / 0.06); + } + + /* ─── Status Indicator Dot Pulse ─────────────────────────────────── */ + @keyframes status-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.3); } + } + .status-dot-pulse { + animation: status-pulse 2s ease-in-out infinite; + } + + /* ─── Count-Up Animation for Numbers ─────────────────────────────── */ + .animate-count-up { + animation: count-up 0.8s ease-out; + } + @keyframes count-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } } /* Print-specific styling — expands to all reports when printing */ diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index df03410..e9c5204 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -42,6 +42,16 @@ import { Plus, Palette, Leaf, + Check, + CheckCircle2, + AlertTriangle, + Info, + BookMarked, + StickyNote, + Award, + UserCheck, + BarChart3, + Trash2, } from 'lucide-react'; import { Sidebar, @@ -90,7 +100,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { ScrollArea } from '@/components/ui/scroll-area'; import { useAppStore, type ViewName } from '@/lib/store'; import { t } from '@/lib/i18n'; -import { fetchSchoolYears, fetchStudents, fetchClasses, fetchNotifications, markServerNotificationsRead, type SchoolYear, type Student, type ClassGroup, type NotificationData, type AssessmentNotification, type MissingObservationNotification } from '@/lib/api'; +import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api'; import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour'; import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog'; import { @@ -104,7 +114,6 @@ import { CommandShortcut, } from '@/components/ui/command'; import { toast } from 'sonner'; -import { AlertTriangle, Info, ArrowRight } from 'lucide-react'; import DashboardView from './dashboard-view'; import ClassesView from './classes-view'; @@ -174,6 +183,22 @@ const navSections: NavSection[] = [ }, ]; +// Student-specific navigation (simplified) +const studentNavSections: NavSection[] = [ + { + id: 'student-main', + labelKey: 'student.dashboard_title', + items: [ + { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, + { key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' }, + { key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' }, + { key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' }, + { key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' }, + { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, + ], + }, +]; + function renderView(view: ViewName) { switch (view) { case 'dashboard': return ; @@ -225,7 +250,7 @@ export default function AppLayout() { const [searchLoading, setSearchLoading] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [notifOpen, setNotifOpen] = useState(false); - const [notifData, setNotifData] = useState(null); + const [notifData, setNotifData] = useState(null); const [notifLoading, setNotifLoading] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -239,7 +264,10 @@ export default function AppLayout() { ? `${currentUser.firstName} ${currentUser.lastName}` : ''; - const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : 'role.super_admin'; + const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : 'role.super_admin'; + + const isStudent = currentUser?.role === 'STUDENT'; + const activeNavSections = isStudent ? studentNavSections : navSections; const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo')); @@ -441,7 +469,7 @@ export default function AppLayout() { if (!currentUser?.schoolId) return; setNotifLoading(true); try { - const data = await fetchNotifications(); + const data = await fetchDBNotifications(); setNotifData(data); } catch { // ignore @@ -452,21 +480,18 @@ export default function AppLayout() { useEffect(() => { loadNotifications(); + // Refresh notifications every 60 seconds + const interval = setInterval(loadNotifications, 60000); + return () => clearInterval(interval); }, [loadNotifications]); const handleMarkAllRead = useCallback(async () => { - if (!notifData) return; - const allIds = [ - ...notifData.upcomingAssessments.map((n) => n.id), - ...notifData.missingObservations.map((n) => n.id), - ]; - if (allIds.length === 0) return; + if (!notifData || notifData.unreadCount === 0) return; try { - await markServerNotificationsRead(allIds); + await markAllNotificationsRead(); setNotifData((prev) => prev ? { ...prev, - upcomingAssessments: prev.upcomingAssessments.map((n) => ({ ...n, isRead: true })), - missingObservations: prev.missingObservations.map((n) => ({ ...n, isRead: true })), + notifications: prev.notifications.map((n) => ({ ...n, isRead: true })), unreadCount: 0, } : prev); } catch { @@ -474,6 +499,87 @@ export default function AppLayout() { } }, [notifData]); + const handleMarkSingleRead = useCallback(async (id: string) => { + try { + await markSingleNotificationRead(id); + setNotifData((prev) => { + if (!prev) return prev; + const updated = prev.notifications.map((n) => + n.id === id ? { ...n, isRead: true } : n + ); + return { + ...prev, + notifications: updated, + unreadCount: updated.filter((n) => !n.isRead).length, + }; + }); + } catch { + // ignore + } + }, []); + + const handleNotificationClick = useCallback((n: DBNotification) => { + if (!n.isRead) { + handleMarkSingleRead(n.id); + } + setNotifOpen(false); + if (n.actionUrl) { + setCurrentView(n.actionUrl as ViewName); + } + }, [handleMarkSingleRead, setCurrentView]); + + // Get icon and color for notification type + const getNotifTypeInfo = (type: string) => { + switch (type) { + case 'ASSESSMENT_DUE': + return { icon: AlertTriangle, bg: 'bg-amber-100/80 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', label: t('notifications.type_assessment_due') }; + case 'MISSING_OBSERVATION': + return { icon: Info, bg: 'bg-teal-100/80 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400', label: t('notifications.type_missing_observation') }; + case 'NOTEBOOK_SHARED': + return { icon: BookMarked, bg: 'bg-emerald-100/80 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400', label: t('notifications.type_notebook_shared') }; + case 'BEHAVIOR_ALERT': + return { icon: Shield, bg: 'bg-rose-100/80 dark:bg-rose-900/20', text: 'text-rose-600 dark:text-rose-400', label: t('notifications.type_behavior_alert') }; + case 'GRADE_COMPUTED': + return { icon: Award, bg: 'bg-violet-100/80 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400', label: t('notifications.type_grade_computed') }; + case 'ATTENDANCE_ALERT': + return { icon: UserCheck, bg: 'bg-orange-100/80 dark:bg-orange-900/20', text: 'text-orange-600 dark:text-orange-400', label: t('notifications.type_attendance_alert') }; + case 'REPORT_READY': + return { icon: FileText, bg: 'bg-cyan-100/80 dark:bg-cyan-900/20', text: 'text-cyan-600 dark:text-cyan-400', label: t('notifications.type_report_ready') }; + default: + return { icon: Bell, bg: 'bg-gray-100/80 dark:bg-gray-900/20', text: 'text-gray-600 dark:text-gray-400', label: t('notifications.type_general') }; + } + }; + + // Group notifications by date + const groupedNotifications = useMemo(() => { + if (!notifData?.notifications) return []; + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86400000); + const weekAgo = new Date(today.getTime() - 7 * 86400000); + + const groups: { label: string; notifications: DBNotification[] }[] = []; + const todayItems: DBNotification[] = []; + const yesterdayItems: DBNotification[] = []; + const weekItems: DBNotification[] = []; + const earlierItems: DBNotification[] = []; + + for (const n of notifData.notifications) { + const date = new Date(n.createdAt); + if (date >= today) todayItems.push(n); + else if (date >= yesterday) yesterdayItems.push(n); + else if (date >= weekAgo) weekItems.push(n); + else earlierItems.push(n); + } + + if (todayItems.length > 0) groups.push({ label: t('notifications.today'), notifications: todayItems }); + if (yesterdayItems.length > 0) groups.push({ label: t('notifications.yesterday'), notifications: yesterdayItems }); + if (weekItems.length > 0) groups.push({ label: t('notifications.this_week'), notifications: weekItems }); + if (earlierItems.length > 0) groups.push({ label: t('notifications.earlier'), notifications: earlierItems }); + + return groups; + }, [notifData?.notifications]); + useEffect(() => { async function loadYears() { if (!currentUser?.schoolId) return; @@ -515,12 +621,13 @@ export default function AppLayout() { - {navSections.map((section) => ( + {activeNavSections.map((section) => ( {section.id === 'analysis' && } {section.id === 'teaching' && } {section.id === 'setup' && } + {section.id === 'student-main' && } {t(section.labelKey)} @@ -711,7 +818,8 @@ export default function AppLayout() { {notifData.unreadCount > 9 ? '9+' : notifData.unreadCount} @@ -720,142 +828,117 @@ export default function AppLayout() { {/* Header */}
-

+

+ {t('notifications.title')}

- {notifData && notifData.unreadCount > 0 && ( - - )} +
+ {notifData && notifData.unreadCount > 0 && ( + + )} +
{/* Content */} - + {notifLoading && (

Loading…

)} - {!notifLoading && notifData && notifData.upcomingAssessments.length === 0 && notifData.missingObservations.length === 0 && ( + {!notifLoading && notifData && notifData.notifications.length === 0 && (

{t('notifications.empty')}

)} - {!notifLoading && notifData && notifData.upcomingAssessments.length > 0 && ( -
-

- - {t('notifications.upcoming_assessments')} + {!notifLoading && groupedNotifications.map((group) => ( +

+

+ {group.label}

- {notifData.upcomingAssessments.map((n: AssessmentNotification, i: number) => ( - -
- -
-
-

{n.title}

-

{n.description}

- - {n.timestamp} - -
- -
- ))} +
+ +
+
+

{n.title}

+

{n.message}

+ + {typeInfo.label} + +
+
+ {!n.isRead && ( + + )} +
+ + ); + })}
- )} - {!notifLoading && notifData && notifData.missingObservations.length > 0 && ( -
-

- - {t('notifications.missing_observations')} -

- {notifData.missingObservations.map((n: MissingObservationNotification, i: number) => ( - -
- -
-
-

{n.title}

-

{n.description}

- - {n.timestamp} - -
- -
- ))} -
- )} - {/* Quick Actions footer */} - {!notifLoading && notifData && (notifData.upcomingAssessments.length > 0 || notifData.missingObservations.length > 0) && ( -
- - - -
- )} + ))} + {/* Footer */} + {!notifLoading && notifData && notifData.notifications.length > 0 && ( +
+ + + +
+ )} {/* Language toggle (mobile: just flag) */} diff --git a/src/components/calendar-view.tsx b/src/components/calendar-view.tsx index 6b18387..da4ce89 100644 --- a/src/components/calendar-view.tsx +++ b/src/components/calendar-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useMemo, useState, useCallback } from 'react'; +import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Calendar as CalendarIcon, @@ -14,6 +14,11 @@ import { Loader2, AlertCircle, Clock, + Plus, + Pencil, + Trash2, + Bell, + CheckCircle2, } from 'lucide-react'; import { format, @@ -22,6 +27,7 @@ import { startOfWeek, endOfWeek, addDays, + addWeeks, isSameDay, isSameMonth, isToday as isDateToday, @@ -30,11 +36,17 @@ import { addDays as dateAddDays, differenceInCalendarDays, isWithinInterval, + setHours, + setMinutes, } from 'date-fns'; import { de as deLocale, enUS as enLocale } from 'date-fns/locale'; import { Card, CardContent, 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 { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; import { Sheet, SheetContent, @@ -48,7 +60,18 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogFooter, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Drawer, DrawerContent, @@ -56,9 +79,28 @@ import { DrawerTitle, DrawerDescription, } from '@/components/ui/drawer'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; -import { fetchCalendarEvents, type CalendarEvent, type CalendarEventType } from '@/lib/api'; +import { + fetchCalendarEvents, + type CalendarEvent, + type CalendarEventType, + createCalendarEventItem, + updateCalendarEventItem, + deleteCalendarEventItem, + type CalendarEventItemType, + fetchClasses, + fetchSubjects, + type ClassGroup, + type Subject, +} from '@/lib/api'; import { toast } from 'sonner'; /* ── Event type visual config ─────────────────────────────────────── */ @@ -115,6 +157,31 @@ const TYPE_CONFIG: Record = { }, }; +/* Custom event type visual config */ +const CUSTOM_TYPE_CONFIG: Record = { + assessment: { + icon: ClipboardCheck, + labelKey: 'calendar.event_types.assessment', + chipBg: 'bg-amber-100 dark:bg-amber-900/30', + chipText: 'text-amber-700 dark:text-amber-300', + dotClass: 'bg-amber-500', + }, + lesson: { + icon: BookOpen, + labelKey: 'calendar.event_types.lesson', + chipBg: 'bg-rose-100 dark:bg-rose-900/30', + chipText: 'text-rose-700 dark:text-rose-300', + dotClass: 'bg-rose-500', + }, + reminder: { + icon: Bell, + labelKey: 'calendar.reminder', + chipBg: 'bg-sky-100 dark:bg-sky-900/30', + chipText: 'text-sky-700 dark:text-sky-300', + dotClass: 'bg-sky-500', + }, +}; + const ALL_TYPES: CalendarEventType[] = ['assessment', 'attendance', 'progress', 'report', 'lesson']; /* ── helpers ──────────────────────────────────────────────────────── */ @@ -124,15 +191,29 @@ function getMonthLabel(date: Date, locale: 'de' | 'en'): string { return format(date, 'MMMM yyyy', { locale: localeObj }); } +function getWeekLabel(date: Date, locale: 'de' | 'en'): string { + const localeObj = locale === 'de' ? deLocale : enLocale; + const start = startOfWeek(date, { weekStartsOn: 1 }); + const end = endOfWeek(date, { weekStartsOn: 1 }); + return `${format(start, 'd. MMM', { locale: localeObj })} – ${format(end, 'd. MMM yyyy', { locale: localeObj })}`; +} + function getWeekdayLabels(locale: 'de' | 'en'): string[] { const localeObj = locale === 'de' ? deLocale : enLocale; - // Monday-first weekday header const base = startOfWeek(new Date(), { weekStartsOn: 1 }); return Array.from({ length: 7 }, (_, i) => format(dateAddDays(base, i), 'EEEEEE', { locale: localeObj }) ); } +function getWeekdayFullLabels(locale: 'de' | 'en'): string[] { + const localeObj = locale === 'de' ? deLocale : enLocale; + const base = startOfWeek(new Date(), { weekStartsOn: 1 }); + return Array.from({ length: 7 }, (_, i) => + format(dateAddDays(base, i), 'EEE', { locale: localeObj }) + ); +} + function buildMonthGrid(monthDate: Date): Date[] { const start = startOfWeek(startOfMonth(monthDate), { weekStartsOn: 1 }); const end = endOfWeek(endOfMonth(monthDate), { weekStartsOn: 1 }); @@ -145,17 +226,49 @@ function buildMonthGrid(monthDate: Date): Date[] { return days; } +function buildWeekGrid(weekDate: Date): Date[] { + const start = startOfWeek(weekDate, { weekStartsOn: 1 }); + return Array.from({ length: 7 }, (_, i) => dateAddDays(start, i)); +} + function formatDayLong(date: Date, locale: 'de' | 'en'): string { const localeObj = locale === 'de' ? deLocale : enLocale; return format(date, 'EEEE, d. MMMM yyyy', { locale: localeObj }); } +/* ── Event Form Data ──────────────────────────────────────────────── */ + +interface EventFormData { + title: string; + date: string; + startTime: string; + endTime: string; + eventType: CalendarEventItemType; + subjectId: string; + classGroupId: string; + notes: string; + allDay: boolean; +} + +const EMPTY_FORM: EventFormData = { + title: '', + date: format(new Date(), 'yyyy-MM-dd'), + startTime: '08:00', + endTime: '09:00', + eventType: 'reminder', + subjectId: '', + classGroupId: '', + notes: '', + allDay: false, +}; + /* ── Sub-components ───────────────────────────────────────────────── */ -function EventDetailContent({ event }: { event: CalendarEvent }) { +function EventDetailContent({ event, onEdit, onDelete }: { event: CalendarEvent; onEdit?: () => void; onDelete?: () => void }) { const cfg = TYPE_CONFIG[event.type]; const Icon = cfg.icon; const meta = event.meta as Record; + const isCustomEvent = meta.customEvent === true; const rows: Array<{ key: string; value: string | number | null | undefined }> = []; if (typeof meta.subject === 'string' && meta.subject) rows.push({ key: 'calendar.meta.subject', value: meta.subject }); @@ -169,6 +282,15 @@ function EventDetailContent({ event }: { event: CalendarEvent }) { if (typeof meta.includesGrades === 'boolean') rows.push({ key: 'calendar.meta.includes_grades', value: meta.includesGrades ? 'Ja' : 'Nein' }); if (typeof meta.count === 'number') rows.push({ key: 'calendar.meta.count', value: meta.count }); + // Custom event specific fields + if (isCustomEvent) { + if (typeof meta.eventType === 'string') rows.push({ key: 'calendar.event_type', value: meta.eventType as string }); + if (typeof meta.startTime === 'string' && meta.startTime) rows.push({ key: 'calendar.start_time', value: meta.startTime as string }); + if (typeof meta.endTime === 'string' && meta.endTime) rows.push({ key: 'calendar.end_time', value: meta.endTime as string }); + if (typeof meta.allDay === 'boolean' && meta.allDay) rows.push({ key: 'calendar.all_day', value: 'Ja' }); + if (typeof meta.notes === 'string' && meta.notes) rows.push({ key: 'calendar.notes', value: meta.notes as string }); + } + // Attendance rates if (typeof meta.rate === 'number') rows.push({ key: 'calendar.meta.rate', value: `${meta.rate}%` }); if (typeof meta.present === 'number') rows.push({ key: 'calendar.meta.present', value: meta.present }); @@ -177,12 +299,12 @@ function EventDetailContent({ event }: { event: CalendarEvent }) { if (typeof meta.late === 'number') rows.push({ key: 'calendar.meta.late', value: meta.late }); return ( -
+
-
+

{event.title}

@@ -212,10 +334,280 @@ function EventDetailContent({ event }: { event: CalendarEvent }) { ))}
)} + {isCustomEvent && (onEdit || onDelete) && ( +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ )}
); } +/* ── Mini Calendar Widget ─────────────────────────────────────────── */ + +function MiniCalendar({ + monthDate, + eventsByDay, + onDateSelect, + onMonthChange, + localeCode, +}: { + monthDate: Date; + eventsByDay: Map; + onDateSelect: (date: Date) => void; + onMonthChange: (date: Date) => void; + localeCode: 'de' | 'en'; +}) { + const today = new Date(); + const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]); + const weekdayLabels = useMemo(() => getWeekdayLabels(localeCode), [localeCode]); + + return ( + + +
+ + + {getMonthLabel(monthDate, localeCode)} + + +
+
+ +
+ {weekdayLabels.map((label) => ( +
+ {label} +
+ ))} +
+
+ {days.map((day, idx) => { + const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsByDay.get(dayKey) ?? []; + const inMonth = isSameMonth(day, monthDate); + const isToday = isDateToday(day); + + return ( + + ); + })} +
+
+
+ ); +} + +/* ── Week View ────────────────────────────────────────────────────── */ + +function WeekView({ + weekDate, + events, + eventsByDay, + onEventClick, + onDayHeaderClick, + onPrevWeek, + onNextWeek, + localeCode, +}: { + weekDate: Date; + events: CalendarEvent[]; + eventsByDay: Map; + onEventClick: (event: CalendarEvent) => void; + onDayHeaderClick: (day: Date) => void; + onPrevWeek: () => void; + onNextWeek: () => void; + localeCode: 'de' | 'en'; +}) { + const days = useMemo(() => buildWeekGrid(weekDate), [weekDate]); + const weekdayLabels = useMemo(() => getWeekdayFullLabels(localeCode), [localeCode]); + const hours = useMemo(() => Array.from({ length: 11 }, (_, i) => i + 7), []); // 7am - 5pm + + const scrollRef = useRef(null); + + // Scroll to 8am on mount + useEffect(() => { + if (scrollRef.current) { + const hourHeight = 48; + scrollRef.current.scrollTop = hourHeight * 1; // 8am + } + }, []); + + return ( + + +
+
+ + + {getWeekLabel(weekDate, localeCode)} + + +
+
+
+ +
+ {/* Time gutter */} +
+
+ {hours.map((hour) => ( +
+ + {String(hour).padStart(2, '0')}:00 + +
+ ))} +
+ + {/* Day columns */} +
+ {/* Day headers */} +
+ {days.map((day, idx) => { + const isToday = isDateToday(day); + const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsByDay.get(dayKey) ?? []; + + return ( + + ); + })} +
+ + {/* Time grid */} +
+
+ {days.map((day, idx) => { + const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsByDay.get(dayKey) ?? []; + + return ( +
+ {hours.map((hour) => ( +
+ ))} + {/* Place events in their time slots */} + {dayEvents.map((ev) => { + const meta = ev.meta as Record; + const cfg = TYPE_CONFIG[ev.type]; + const Icon = cfg.icon; + + // Calculate position based on start time + let topOffset = 0; + let eventHeight = 44; + const startTime = typeof meta.startTime === 'string' ? meta.startTime : null; + const endTime = typeof meta.endTime === 'string' ? meta.endTime : null; + + if (startTime) { + const [startH, startM] = startTime.split(':').map(Number); + const startMinutes = startH * 60 + startM; + const gridStartMinutes = 7 * 60; // 7am + topOffset = Math.max(0, ((startMinutes - gridStartMinutes) / 60) * 48); + + if (endTime) { + const [endH, endM] = endTime.split(':').map(Number); + const endMinutes = endH * 60 + endM; + const duration = Math.max(15, endMinutes - startMinutes); + eventHeight = Math.max(20, (duration / 60) * 48); + } + } else { + // All-day or no time, place at top + topOffset = 4; + eventHeight = 28; + } + + return ( + + ); + })} +
+ ); + })} +
+
+
+
+ + {events.length === 0 && ( +
+ +

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

+
+ )} + + + ); +} + /* ── Main component ───────────────────────────────────────────────── */ export default function CalendarView() { @@ -223,18 +615,35 @@ export default function CalendarView() { const locale = useAppStore((s) => s.locale); const localeCode = (locale === 'en' ? 'en' : 'de') as 'de' | 'en'; - // Month state — defaults to today + // Month state const [monthDate, setMonthDate] = useState(() => startOfMonth(new Date())); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // View mode + const [viewMode, setViewMode] = useState<'month' | 'week'>('month'); + const [weekDate, setWeekDate] = useState(() => new Date()); + // Selected day (opens sheet) const [selectedDay, setSelectedDay] = useState(null); - // Selected event (opens popover) — keyed by event id + // Selected event (opens detail dialog) const [selectedEvent, setSelectedEvent] = useState(null); - // Detect mobile (for bottom drawer vs side sheet) + // Event form dialog + const [showEventForm, setShowEventForm] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + const [formData, setFormData] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + + // Delete confirmation + const [deleteConfirmEvent, setDeleteConfirmEvent] = useState(null); + + // Classes and subjects for form + const [classes, setClasses] = useState([]); + const [subjects, setSubjects] = useState([]); + + // Detect mobile const [isMobile, setIsMobile] = useState(false); useEffect(() => { const mql = window.matchMedia('(max-width: 768px)'); @@ -267,7 +676,24 @@ export default function CalendarView() { loadEvents(); }, [loadEvents]); - // Group events by YYYY-MM-DD for quick lookup + // Load classes and subjects for form + useEffect(() => { + async function load() { + try { + const [cls, subjs] = await Promise.all([ + fetchClasses(currentUser?.schoolId ?? undefined), + fetchSubjects(currentUser?.schoolId ?? undefined), + ]); + setClasses(cls); + setSubjects(subjs); + } catch { + // ignore + } + } + if (currentUser?.schoolId) load(); + }, [currentUser?.schoolId]); + + // Group events by YYYY-MM-DD const eventsByDay = useMemo(() => { const map = new Map(); for (const e of events) { @@ -278,7 +704,7 @@ export default function CalendarView() { return map; }, [events]); - // Upcoming events (next 5 from today, regardless of current month view) + // Upcoming events const upcomingEvents = useMemo(() => { const today = new Date(); today.setHours(0, 0, 0, 0); @@ -296,9 +722,18 @@ export default function CalendarView() { const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]); const weekdayLabels = useMemo(() => getWeekdayLabels(localeCode), [localeCode]); - const handlePrev = useCallback(() => setMonthDate((d) => addMonths(d, -1)), []); - const handleNext = useCallback(() => setMonthDate((d) => addMonths(d, 1)), []); - const handleToday = useCallback(() => setMonthDate(startOfMonth(new Date())), []); + const handlePrev = useCallback(() => { + if (viewMode === 'month') setMonthDate((d) => addMonths(d, -1)); + else setWeekDate((d) => addWeeks(d, -1)); + }, [viewMode]); + const handleNext = useCallback(() => { + if (viewMode === 'month') setMonthDate((d) => addMonths(d, 1)); + else setWeekDate((d) => addWeeks(d, 1)); + }, [viewMode]); + const handleToday = useCallback(() => { + if (viewMode === 'month') setMonthDate(startOfMonth(new Date())); + else setWeekDate(new Date()); + }, [viewMode]); const selectedDayEvents = useMemo(() => { if (!selectedDay) return []; @@ -306,6 +741,89 @@ export default function CalendarView() { return eventsByDay.get(key) ?? []; }, [selectedDay, eventsByDay]); + // Event form handlers + const handleNewEvent = useCallback((date?: string) => { + setEditingEvent(null); + setFormData({ + ...EMPTY_FORM, + date: date ?? format(new Date(), 'yyyy-MM-dd'), + }); + setShowEventForm(true); + }, []); + + const handleEditEvent = useCallback((event: CalendarEvent) => { + const meta = event.meta as Record; + setEditingEvent(event); + setFormData({ + title: event.title, + date: event.date, + startTime: (typeof meta.startTime === 'string' && meta.startTime) ? meta.startTime : '08:00', + endTime: (typeof meta.endTime === 'string' && meta.endTime) ? meta.endTime : '09:00', + eventType: (typeof meta.eventType === 'string' ? meta.eventType : 'reminder') as CalendarEventItemType, + subjectId: '', + classGroupId: '', + notes: (typeof meta.notes === 'string' && meta.notes) ? meta.notes : '', + allDay: typeof meta.allDay === 'boolean' ? meta.allDay : false, + }); + setShowEventForm(true); + setSelectedEvent(null); + }, []); + + const handleDeleteEvent = useCallback(async () => { + if (!deleteConfirmEvent) return; + try { + await deleteCalendarEventItem(deleteConfirmEvent.id); + toast.success(t('calendar.event_deleted')); + loadEvents(); + } catch { + toast.error(t('calendar.event_delete_error')); + } + setDeleteConfirmEvent(null); + setSelectedEvent(null); + }, [deleteConfirmEvent, loadEvents]); + + const handleSaveEvent = useCallback(async () => { + if (!currentUser?.schoolId || !formData.title.trim()) return; + setSaving(true); + try { + if (editingEvent) { + await updateCalendarEventItem(editingEvent.id, { + title: formData.title, + date: formData.date, + startTime: formData.allDay ? null : formData.startTime, + endTime: formData.allDay ? null : formData.endTime, + eventType: formData.eventType, + subjectId: formData.subjectId || null, + classGroupId: formData.classGroupId || null, + notes: formData.notes || null, + allDay: formData.allDay, + }); + toast.success(t('calendar.event_updated')); + } else { + await createCalendarEventItem({ + schoolId: currentUser.schoolId, + title: formData.title, + date: formData.date, + startTime: formData.allDay ? null : formData.startTime, + endTime: formData.allDay ? null : formData.endTime, + eventType: formData.eventType, + subjectId: formData.subjectId || null, + classGroupId: formData.classGroupId || null, + notes: formData.notes || null, + allDay: formData.allDay, + }); + toast.success(t('calendar.event_created')); + } + setShowEventForm(false); + setEditingEvent(null); + loadEvents(); + } catch { + toast.error(editingEvent ? t('calendar.event_update_error') : t('calendar.event_create_error')); + } finally { + setSaving(false); + } + }, [currentUser?.schoolId, formData, editingEvent, loadEvents]); + return (
{/* Header */} @@ -329,6 +847,25 @@ export default function CalendarView() {
+ {/* View mode toggle */} +
+ + +
- {getMonthLabel(monthDate, localeCode)} + {viewMode === 'month' ? getMonthLabel(monthDate, localeCode) : getWeekLabel(weekDate, localeCode)}
+ {/* New Event button */} +
@@ -388,229 +934,277 @@ export default function CalendarView() { ); })} + {/* Reminder type */} + + + {t('calendar.reminder')} + - {/* Calendar grid */} - - - - {/* Weekday header */} -
- {weekdayLabels.map((label) => ( -
- {label} + {/* Main content area - sidebar + calendar */} +
+ {/* Sidebar with mini calendar */} + + { + setMonthDate(startOfMonth(date)); + setWeekDate(date); + if (eventsByDay.get(format(date, 'yyyy-MM-dd'))?.length) { + setSelectedDay(date); + } + }} + onMonthChange={(date) => setMonthDate(date)} + localeCode={localeCode} + /> + + {/* Upcoming this week */} + + + + + {t('calendar.upcoming_week')} + + + + {upcomingEvents.length === 0 ? ( +
+ +

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

- ))} -
- - {/* Day cells */} -
- {days.map((day, idx) => { - const dayKey = format(day, 'yyyy-MM-dd'); - const dayEvents = eventsByDay.get(dayKey) ?? []; - const inMonth = isSameMonth(day, monthDate); - const isToday = isDateToday(day); - const maxVisible = 3; - const visibleEvents = dayEvents.slice(0, maxVisible); - const overflow = dayEvents.length - maxVisible; - - return ( - dayEvents.length > 0 && setSelectedDay(day)} - className={`relative min-h-[80px] p-1 rounded-md border transition-all ${ - inMonth - ? 'bg-white dark:bg-gray-900/40 border-gray-100 dark:border-gray-800' - : 'bg-gray-50/60 dark:bg-gray-900/20 border-transparent' - } ${ - dayEvents.length > 0 && inMonth - ? 'cursor-pointer hover:shadow-md hover:border-emerald-200 dark:hover:border-emerald-800/60 hover:-translate-y-0.5' - : '' - } ${isToday ? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-white dark:ring-offset-gray-950' : ''}`} - > - {/* Day number */} -
- - {format(day, 'd')} - - {dayEvents.length > 0 && inMonth && ( - - {dayEvents.length} - - )} -
- - {/* Empty cell placeholder */} - {inMonth && dayEvents.length === 0 && ( - — - )} - - {/* Event chips */} -
- {visibleEvents.map((ev) => { - const cfg = TYPE_CONFIG[ev.type]; - const Icon = cfg.icon; - return ( + ) : ( +
    + + {upcomingEvents.map((ev, idx) => { + const cfg = TYPE_CONFIG[ev.type]; + const Icon = cfg.icon; + const evDate = parseISO(ev.date); + const relativeDays = differenceInCalendarDays(evDate, new Date()); + const relativeLabel = + relativeDays === 0 + ? localeCode === 'de' ? 'Heute' : 'Today' + : relativeDays === 1 + ? localeCode === 'de' ? 'Morgen' : 'Tomorrow' + : format(evDate, 'EEE, d. MMM', { + locale: localeCode === 'de' ? deLocale : enLocale, + }); + return ( + - ); - })} - {overflow > 0 && ( - - )} + + ); + })} + +
+ )} + + + + + {/* Calendar grid / Week view */} + + {viewMode === 'week' ? ( + setSelectedEvent(ev)} + onDayHeaderClick={(day) => setSelectedDay(day)} + onPrevWeek={() => setWeekDate((d) => addWeeks(d, -1))} + onNextWeek={() => setWeekDate((d) => addWeeks(d, 1))} + localeCode={localeCode} + /> + ) : ( + + + {/* Weekday header */} +
+ {weekdayLabels.map((label) => ( +
+ {label}
- - ); - })} -
+ ))} +
- {/* Empty + Loading + Error states */} - {loading && ( -
- - {t('calendar.loading')} -
- )} - {error && !loading && ( -
- - {error} -
- )} - {!loading && !error && events.length === 0 && ( -
- -

- {t('calendar.no_events')} -

-

- {t('calendar.no_events_desc')} -

-
- )} - - -
+ {/* Day cells */} +
+ {days.map((day, idx) => { + const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsByDay.get(dayKey) ?? []; + const inMonth = isSameMonth(day, monthDate); + const isToday = isDateToday(day); + const maxVisible = 3; + const visibleEvents = dayEvents.slice(0, maxVisible); + const overflow = dayEvents.length - maxVisible; - {/* Upcoming this week */} - - - - - - {t('calendar.upcoming_week')} - - - - {upcomingEvents.length === 0 ? ( -
- -

- {t('calendar.no_upcoming')} -

-
- ) : ( -
    - - {upcomingEvents.map((ev, idx) => { - const cfg = TYPE_CONFIG[ev.type]; - const Icon = cfg.icon; - const evDate = parseISO(ev.date); - const relativeDays = differenceInCalendarDays(evDate, new Date()); - const relativeLabel = - relativeDays === 0 - ? localeCode === 'de' - ? 'Heute' - : 'Today' - : relativeDays === 1 - ? localeCode === 'de' - ? 'Morgen' - : 'Tomorrow' - : format(evDate, 'EEE, d. MMM', { - locale: localeCode === 'de' ? deLocale : enLocale, - }); return ( - dayEvents.length > 0 && setSelectedDay(day)} + className={`relative min-h-[80px] p-1 rounded-md border transition-all ${ + inMonth + ? 'bg-white dark:bg-gray-900/40 border-gray-100 dark:border-gray-800' + : 'bg-gray-50/60 dark:bg-gray-900/20 border-transparent' + } ${ + dayEvents.length > 0 && inMonth + ? 'cursor-pointer hover:shadow-md hover:border-emerald-200 dark:hover:border-emerald-800/60 hover:-translate-y-0.5' + : '' + } ${isToday ? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-white dark:ring-offset-gray-950' : ''}`} > - - + {/* Day number */} +
    + + {format(day, 'd')} + + {dayEvents.length > 0 && inMonth && ( + + {dayEvents.length} + + )} +
    + + {/* Empty cell placeholder */} + {inMonth && dayEvents.length === 0 && ( + + )} + + {/* Event chips */} +
    + {visibleEvents.map((ev) => { + const cfg = TYPE_CONFIG[ev.type]; + const Icon = cfg.icon; + return ( + + ); + })} + {overflow > 0 && ( + + )} +
    + ); })} -
    -
- )} -
-
-
+
+ + {/* Empty + Loading + Error states */} + {loading && ( +
+ + {t('calendar.loading')} +
+ )} + {error && !loading && ( +
+ + {error} +
+ )} + {!loading && !error && events.length === 0 && ( +
+ +

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

+

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

+ +
+ )} + + + )} + +
{/* Day Events Sheet (desktop) / Drawer (mobile) */} {isMobile ? ( @@ -641,9 +1235,7 @@ export default function CalendarView() {
  • @@ -702,9 +1306,7 @@ export default function CalendarView() {
  • @@ -734,7 +1348,7 @@ export default function CalendarView() { {/* Event detail dialog */} !open && setSelectedEvent(null)} > {selectedEvent ? (
    - + )?.customEvent ? () => handleEditEvent(selectedEvent) : undefined} + onDelete={(selectedEvent.meta as Record)?.customEvent ? () => setDeleteConfirmEvent(selectedEvent) : undefined} + />
    @@ -768,6 +1386,226 @@ export default function CalendarView() { ) : null}
    + + {/* Event form dialog */} + { + if (!open) { + setShowEventForm(false); + setEditingEvent(null); + } + }} + > + + + + {editingEvent ? t('calendar.edit_event') : t('calendar.new_event')} + + + {editingEvent ? t('calendar.edit_event') : t('calendar.new_event')} + + +
    + {/* Title */} +
    + + setFormData((f) => ({ ...f, title: e.target.value }))} + placeholder={t('calendar.new_event')} + className="min-h-[44px]" + /> +
    + + {/* Date */} +
    + + setFormData((f) => ({ ...f, date: e.target.value }))} + className="min-h-[44px]" + /> +
    + + {/* All day toggle */} +
    + + setFormData((f) => ({ ...f, allDay: checked }))} + /> +
    + + {/* Start/End time (if not all day) */} + {!formData.allDay && ( +
    +
    + + setFormData((f) => ({ ...f, startTime: e.target.value }))} + className="min-h-[44px]" + /> +
    +
    + + setFormData((f) => ({ ...f, endTime: e.target.value }))} + className="min-h-[44px]" + /> +
    +
    + )} + + {/* Event type */} +
    + + +
    + + {/* Subject */} +
    + + +
    + + {/* Class */} +
    + + +
    + + {/* Notes */} +
    + +